Skip to content

Instantly share code, notes, and snippets.

@sini
Last active June 1, 2026 00:12
Show Gist options
  • Select an option

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

Select an option

Save sini/945f6bff8dc232fbeb3aca66b83d293c to your computer and use it in GitHub Desktop.
gen-vars — den-agnostic pure-nix vars/secrets library + gen/ integration demo (2026-05-31)

gen-vars — den-agnostic pure-nix vars/secrets library + den adapter

Date: 2026-05-31 Status: Design (brainstormed, design-workflow synthesized, adversarially critiqued, all open questions resolved) Companions:

  • nixpkgs PR #370444 (lassulus, upstream vars) — the proven generator model this extracts the pure algebra from.
  • ~/Documents/repos/sini/agenix-rekey — the system-agnostic decoupling precedent (extraConfigurations, node-by-config.age).
  • 2026-05-31-settings-stratification-injection-design.md — the Spike 5 genBind.wrap injection pattern the gen/ integration demo mirrors.
  • 2026-05-27-den-pragmatic-fleet-features.md §Spike 1 — the original (Clan-shaped) sketch this supersedes.

A new den-agnostic pure-nix library (github:sini/gen-vars) that owns the generator data model, dependency-DAG ordering, a backend-agnostic generation plan, and a multi-target resolution interface. den imports gen-vars; gen-vars never imports den. All den-specific wiring (scope-driven generator assignment, per-class resolvers, injection) lives in a separate, fresh integration demo in the gen/ ecosystem hub (gen/examples/gen-vars/), not in gen-vars.

Load-bearing claims verified against source during design are flagged [V].


1. Overview & principles

What gen-vars is

A generator declares one or more files produced by a script, optionally depending on other generators' outputs ($in), prompting for input ($prompts), and writing results to $out — the proven nixpkgs vars model. gen-vars extracts the pure algebra of that model into a den-unaware library, and lets any consumer (den, a raw flake, terranix, k8s/nixidy) stitch the primitives together. The motivation: nixpkgs vars is nixos-coupled by packaging (mounted in options.vars, single machine backend), not by concept; gen-vars keeps the generator core target-agnostic and pushes coupling to the consumer.

Locked principles

  1. Den-agnostic pure-nix library. gen-vars MUST NOT import or reference den/gen-aspects/scope/classes. Coupling lives in the den adapter. den imports gen-vars, never the reverse.
  2. Thin pure core. gen-vars owns ONLY: the generator data model, DAG resolution + topological ordering, a normalized backend-agnostic generation plan ($in/$out/$prompts contract), and the multi-target resolve interface. Impure execution is never done by the library — it is emitted (a script/derivation) by a backend or run by a consumer.
  3. Option-1 core + Option-3 adapter (both shipped).
    • Core (Option 1): a generated file is a pure handle { generator; name; secret; }. gen-vars defines a resolution interface resolve : handle -> resolution; the core holds no resolution. The same handle can be resolved by multiple resolvers in one evaluation (the multi-target property).
    • Adapter (Option 3): a module/ tier provides a deferredModule fileModule slot that implements resolve for module-system consumers — buys .path ergonomics and a near-drop-in nixpkgs-vars interop bridge, strictly on top of the pure core.
  4. Pluggable primitives. Generators and resolvers are composable, den-unaware values a consumer stitches together.
  5. The consumer wiring is separate (a fresh, lean integration demo in the gen/ hub, gen/examples/gen-vars/): (a) a generation pass that uses the scope graph to decide which entity gets which generators; (b) a consumption pass with a per-class resolver registry, routing resolved handles into class content via the genBind.wrap injection pattern (proven by Spike 5).

Resolved decisions (this spike)

  • Generator declaration channel: a generators aspectModule option on aspects + scope-driven per-host selection. (The gen/ demo realizes selection with a leaner genScope.inheritAll over the scope graph — the hub has no policy engine — so graph topology is the selection mechanism; a full den adapter would use a provide-generator policy action. Same concept, leaner demo.) The env/host cascade over generator sets (a raw-generators neron traverse parallel to raw-settings) is out of scope.
  • Resolver ownership: a central classResolvers registry for the spike; per-class-declaration (cnf.classes.<name>.resolve) is a clean follow-up.
  • vars visibility: host-global (vars.<generator>.<file> — any aspect's class fn can read any of the host's handles), deliberately unlike per-aspect settings. Generators are a host-level resource.
  • gen-graph in order/: optional enhancement — ordering + cycle detection always run on lib.toposort; gen-graph enriches impact/dep diagnostics when present.

Tier discipline (the central invariant)

[V] gen-algebra's pure/default.nix takes no arguments and only does import ./x.nix — genuinely lib-free; its root default.nix is { lib ? null }: with a module fallback. gen-vars mirrors this exactly. Dependencies flow strictly upward:

pure/      lib-free, gen-free, den-free          (handle, generator, resolve)
  ▲
order/     lib + (optional) genGraph             (toposort, plan, cycle/impact diagnostics)
  ▲
module/    lib + module system                   (deferredModule resolver, registry, nixpkgs-vars bridge)
  ▲
backend/   lib + pkgs                            (emit a generate script — runs nothing)
  ▲
(gen/ integration demo, separate)                (scope generation + per-class resolvers + injection)

The original design leaked { lib, genGraph } into the "pure" tier — a category regression against gen-algebra's verifiably zero-dep pure tier. Fix (adopted): pure/ is 100% lib-free (builtins only); all lib/genGraph code (toposort, plan, cycle/impact) lives in a separate order/ tier. The no-flakes fetchTarball-from-ci/flake.lock peer-resolution (the gen-schema pattern) lives only in lib-applied tiers, never in pure/ (no IO at import).


2. gen-vars pure core

2.1 pure/default.nix — aggregator (lib-free, zero-arg)

# Mirrors gen-algebra/pure/default.nix: no args, plain imports.
let
  generator = import ./generator.nix;
  handle    = import ./handle.nix;
  resolve   = import ./resolve.nix;
in
{
  inherit (generator) mkGenerator normalizeGenerator validateGenerator;
  inherit (handle) mkHandle handleId handlesOf;
  inherit (resolve) mkResolver resolve resolveAll;
}

2.2 pure/generator.nix — generator data model (lib-free)

Pure data = the nixpkgs-vars generator shape minus path (paths are resolution outputs). validateGenerator is a separate pure pass returning [errors] (not a throw in mkGenerator) so the core stays lazy/composable; the module tier or den adapter chooses when to enforce.

# NO lib. builtins only.
let
  nameRe = "[a-zA-Z0-9:_.-]+";
  isName = s: builtins.isString s && builtins.match nameRe s != null;
  promptTypes = [ "hidden" "line" "multiline" ];

  optional = c: v: if c then [ v ] else [ ];
  concatLists = builtins.concatLists;
  attrsToList = f: attrs: map (k: f k attrs.${k}) (builtins.attrNames attrs);

  normalizeFile = genName: fileName: f: {
    name      = fileName;
    generator = genName;              # backref
    secret    = f.secret or true;
    deploy    = f.deploy or true;     # deploy=false => input-only, never materialized
  };
  normalizePrompt = promptName: p: {
    name = promptName; description = p.description or promptName; type = p.type or "line";
  };

  mkGenerator = name: g: {
    inherit name;
    dependencies  = g.dependencies or [ ];
    files         = builtins.mapAttrs (normalizeFile name) (g.files or { });
    prompts       = builtins.mapAttrs normalizePrompt (g.prompts or { });
    runtimeInputs = g.runtimeInputs or [ ];
    script        = g.script or "";
  };
  normalizeGenerator = mkGenerator;

  # File/prompt validation is INDEPENDENT of name validity (the original used a
  # precedence accident that suppressed file checks on the worst inputs).
  validateGenerator = g:
    optional (!isName g.name) "gen-vars: invalid generator name ${builtins.toJSON g.name}"
    ++ concatLists (attrsToList
        (fn: _: optional (!isName fn)
          "gen-vars: invalid file name ${builtins.toJSON fn} in generator ${g.name}")
        g.files)
    ++ concatLists (attrsToList
        (pn: p: optional (!builtins.elem p.type promptTypes)
          "gen-vars: invalid prompt type ${builtins.toJSON p.type} for prompt ${pn} in generator ${g.name}")
        g.prompts);
in
{ inherit mkGenerator normalizeGenerator validateGenerator; }

2.3 pure/handle.nix — pure file handle (lib-free)

Identity is the plain string key ${generator}/${name}not gen-algebra mkIntensional (that is name-only closure equality for search.converge dedup; handles are records, == already suffices). No deploy on the handledeploy lives only on plan-entry files (a backend gates materialization on it; a resolver never needs it).

{
  mkHandle = { generator, name, secret ? true }: { inherit generator name secret; };
  handleId = h: "${h.generator}/${h.name}";
  handlesOf = g:
    map (f: { generator = g.name; name = f.name; secret = f.secret; })
      (builtins.attrValues g.files);
}

2.4 pure/resolve.nix — the multi-target resolution interface (lib-free)

The core holds NO resolution. A resolver is a plain handle -> resolution; the SAME handle is fed to MANY resolvers in one eval via resolveAll. resolution is an OPEN type — a resolver returns any class-native value ({ path = …; } / { ref = …; } are illustrative conventions, not a closed enum). This is what lets den's resolvers (bare strings, { secretRef = …; }) genuinely use resolveAll rather than bypass it.

{
  mkResolver = f: f;                                 # identity/doc tag
  resolve = resolver: handle: resolver handle;
  # THE MULTI-TARGET PROPERTY: same handle, >=2 resolvers, ONE evaluation.
  resolveAll = resolvers: handle:
    builtins.mapAttrs (_target: resolver: resolver handle) resolvers;
}

2.5 order/default.nix — DAG ordering + plan (lib-applied; genGraph optional)

[V] gen-graph has no toposort (lib/ = edge-maps, enumerate, fixpoint, global, registry, traverse, default — zero ordering). lib.toposort (a: b: builtins.elem a.name b.dependencies) (lib.attrValues gens) returns { result = [...]; } on success, { cycle; loops; } (no .result) on a cycle. So ordering/cycle-detection run on lib.toposort; gen-graph (when present) only enriches cycle text + impact/dep sets.

{ lib, genGraph ? null }:
let
  depGraph = gens: { edges = id: gens.${id}.dependencies or [ ]; nodes = builtins.attrNames gens; };

  reachLibOnly = gens: start:
    let go = seen: frontier:
      if frontier == [ ] then seen
      else let n = builtins.head frontier;
               fresh = builtins.filter (d: !(builtins.elem d seen)) (gens.${n}.dependencies or [ ]);
           in go (seen ++ fresh) (builtins.tail frontier ++ fresh);
    in go [ ] [ start ];
  dependentsLibOnly = gens: target:
    builtins.filter (n: builtins.elem target (reachLibOnly gens n)) (builtins.attrNames gens);

  mkPlan = generators:
    let
      g = depGraph generators;
      # 1. missing-dependency check BEFORE toposort (typo'd dep => clear error, not silent drop).
      missing = lib.flatten (lib.mapAttrsToList (n: gen:
        map (d: { from = n; dep = d; }) (builtins.filter (d: !(generators ? ${d})) gen.dependencies)) generators);
      missingErr = lib.optional (missing != [ ])
        "gen-vars: unknown generator dependencies: ${builtins.concatStringsSep ", " (map (m: "${m.from} -> ${m.dep}") missing)}";
      # 2. order + cycle detection.
      sorted = lib.toposort (a: b: builtins.elem a.name b.dependencies) (builtins.attrValues generators);
      cycleNodes =
        if !(sorted ? cycle) then [ ]
        else if genGraph != null then genGraph.cycles g
        else map (r: r.name) (sorted.loops or sorted.cycle);
      cycleErr = lib.optional (sorted ? cycle)
        "gen-vars: dependency cycle among generators: ${builtins.concatStringsSep " -> " cycleNodes}";
      errors = missingErr ++ cycleErr;
      toEntry = gen: {
        inherit (gen) name dependencies runtimeInputs script;
        files   = builtins.attrValues gen.files;      # [{ name; generator; secret; deploy; }]
        prompts = builtins.attrValues gen.prompts;    # [{ name; description; type; }]
        io = { out = "$out"; deps = "$in"; prompts = "$prompts"; };
      };
    in
    if errors != [ ] then throw (builtins.concatStringsSep "\n" errors)
    else {
      order    = map toEntry sorted.result;
      impactOf = name: if genGraph != null then genGraph.dependentsOf g name else dependentsLibOnly generators name;
      depsOf   = name: if genGraph != null then genGraph.reachableFrom g name else reachLibOnly generators name;
    };
in
{ inherit mkPlan depGraph; }

2.6 Data model summary

HANDLE (pure, Option-1 core; carries NO resolution):
  handle = { generator : str; name : str; secret : bool; };  handleId h = "${h.generator}/${h.name}"

RESOLVER + RESOLUTION (the multi-target interface gen-vars owns):
  resolution = <any class-native value>            # OPEN: path str, ref str, attrset, ...
  resolver   = handle -> resolution
  resolveAll : { <target> = resolver; } -> handle -> { <target> = resolution; }

GENERATOR (pure data = nixpkgs-vars shape MINUS `path`):
  generator = { name; dependencies:[str]; files:{<f>=fileSpec;}; prompts:{<p>=promptSpec;}; runtimeInputs:[pkg]; script:str|path; }
  fileSpec  = { name; generator; secret:bool=true; deploy:bool=true; }
  promptSpec= { name; description=name; type:enum["hidden" "line" "multiline"]="line"; }

PLAN (backend-agnostic; from order/.mkPlan):
  plan = { order:[planEntry]; impactOf:name->[name]; depsOf:name->[name]; }
  planEntry = { name; dependencies; runtimeInputs; script; files:[fileSpec]; prompts:[promptSpec]; io={out;deps;prompts}; }

DELIBERATELY NOT IN THE CORE: class/scope/settings/injection knowledge; filesystem/path policy; impure execution;
  gen-algebra identity; gen-schema registry; gen-graph toposort (none exists).

3. module/ adapter + nixpkgs-vars interop

The pure core defines resolve but holds no resolution. module/ implements one concrete resolver for module-system consumers via the nixpkgs-vars fileModule seam, and exposes a plain attrsOf submodule registry structurally identical to vars.generators — so a generator authored for nixpkgs vars evaluates here unchanged.

3.1 module/file-module.nix

{ lib }:
{
  fileModuleSlot = lib.mkOption {
    type = lib.types.deferredModule; internal = true; default = { };
    description = ''
      Imported into every files.<f> submodule. A resolver returns an attrset setting at least
      `path`. ONE deferredModule slot per evaluation — that single-target ceiling is exactly why
      den's MULTI-target lives in the den adapter's per-class resolver registry, not here.
    '';
  };
  mkOnMachineResolver = { fileLocation }: file: {
    path = let bucket = if file.config.secret then "secret" else "public";
           in "${fileLocation}/${bucket}/${file.config.generator}/${file.config.name}";
  };
}

3.2 module/registry.nix — plain attrsOf submodule (NOT gen-schema) = nixpkgs-vars interop

{ lib }:
let
  mkFileType = settings: lib.types.submodule (file: {
    imports = [ settings.fileModule ];                              # the resolver seam (Option 3)
    options = {
      name      = lib.mkOption { type = lib.types.strMatching "[a-zA-Z0-9:_.-]*"; readOnly = true; default = file.config._module.args.name; };
      generator = lib.mkOption { type = lib.types.str; readOnly = true; internal = true; };
      deploy    = lib.mkOption { type = lib.types.bool; default = true; };
      secret    = lib.mkOption { type = lib.types.bool; default = true; };
      path      = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; };
      value     = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; };
    };
  });
in
rec {
  generatorsType = settings: lib.types.attrsOf (lib.types.submodule (gen: {
    options = {
      name          = lib.mkOption { type = lib.types.strMatching "[a-zA-Z0-9:_.-]*"; readOnly = true; default = gen.config._module.args.name; };
      dependencies  = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; };
      files         = lib.mkOption { type = lib.types.attrsOf (mkFileType settings); default = { }; };
      prompts       = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule (p: {
        options = {
          name        = lib.mkOption { type = lib.types.str; default = p.config._module.args.name; };
          description = lib.mkOption { type = lib.types.str; default = p.config._module.args.name; };
          type        = lib.mkOption { type = lib.types.enum [ "hidden" "line" "multiline" ]; default = "line"; };
        };
      })); default = { }; };
      runtimeInputs = lib.mkOption { type = lib.types.listOf lib.types.package; default = [ ]; };
      script        = lib.mkOption { type = lib.types.either lib.types.str lib.types.path; default = ""; };
    };
  }));
  generatorsOption = settings: lib.mkOption { type = generatorsType settings; default = { }; };
}

Interop: the file submodule field names (generator/name/secret) are 1:1 with the pure-core handle, so config.vars.generators.<g>.files.<f> is a core handle modulo .path/.value. runtimeInputs defaults [] in the registry (no pkgs in module tier); the backend prepends coreutils to PATH itself.


3.3 module/default.nix — aggregator

# gen-vars/module/default.nix
{ lib }: (import ./file-module.nix { inherit lib; }) // (import ./registry.nix { inherit lib; })

4. Reference backend — pure emission of a generate script

backend/on-machine.nix consumes the topo-ordered plan and emits a writeShellApplication reproducing the proven on-machine semantics: per-generator tri-state consistency check (all-present → skip; all-missing → generate; mixed → bail), prompt collection, $in dep staging from secret/public trees, $out capture, post-run verification, then materialize into <loc>/{secret,public}/<gen>/<file> honoring per-file secret + deploy. The library runs nothing.

{ lib }:
{ pkgs, plan, fileLocation ? "/etc/vars" }:
let
  entries = plan.order;
  promptCmd = { hidden = "read -sr prompt_value"; line = "read -r prompt_value";
                multiline = "echo 'press control-d to finish'\n        prompt_value=$(cat)"; };
  pathOf = gen: file: "$OUT_DIR/${if file.secret then "secret" else "public"}/${gen.name}/${file.name}";
  byName = lib.listToAttrs (map (g: lib.nameValuePair g.name g) entries);
  genBlock = gen: ''
    all_files_missing=true ; all_files_present=true
    ${lib.concatMapStringsSep "\n" (file: ''
      if test -e ${lib.escapeShellArg (pathOf gen file)} ; then all_files_missing=false ; else all_files_present=false ; fi
    '') gen.files}
    if [ $all_files_missing = false ] && [ $all_files_present = false ] ; then
      echo "gen-vars: inconsistent state for generator: ${gen.name}" ; exit 1
    fi
    if [ $all_files_present = true ] ; then echo "all files for ${gen.name} present"
    elif [ $all_files_missing = true ] ; then
      prompts=$(mktemp -d) ; export prompts ; trap 'rm -rf "$prompts"' EXIT
      ${lib.concatMapStringsSep "\n" (p: ''
        echo ${lib.escapeShellArg p.description}
        ${promptCmd.${p.type}}
        printf '%s' "$prompt_value" > "$prompts"/${p.name}
      '') gen.prompts}
      in=$(mktemp -d) ; export in ; trap 'rm -rf "$in"' EXIT
      ${lib.concatMapStringsSep "\n" (dep: ''
        mkdir -p "$in"/${dep}
        ${lib.concatMapStringsSep "\n" (f: ''
          cp "$OUT_DIR"/${if f.secret then "secret" else "public"}/${dep}/${f.name} "$in"/${dep}/${f.name}
        '') (byName.${dep}.files or [ ])}
      '') gen.dependencies}
      out=$(mktemp -d) ; export out ; trap 'rm -rf "$out"' EXIT
      ( unset PATH
        ${lib.optionalString (gen.runtimeInputs != [ ]) ''PATH=${lib.makeBinPath gen.runtimeInputs} ; export PATH''}
        ${gen.script} )
      ${lib.concatMapStringsSep "\n" (f: ''
        test -e "$out"/${f.name} || { echo "gen-vars: ${gen.name} failed to produce ${f.name}" ; exit 1 ; }
      '') gen.files}
      ${lib.concatMapStringsSep "\n" (f: lib.optionalString f.deploy ''
        OUT_FILE=${lib.escapeShellArg (pathOf gen f)}
        mkdir -p "$(dirname "$OUT_FILE")" ; mv "$out"/${f.name} "$OUT_FILE"
      '') gen.files}
    fi
  '';
in
pkgs.writeShellApplication {
  name = "generate-vars";
  runtimeInputs = [ pkgs.coreutils ];
  text = ''
    set -efuo pipefail
    OUT_DIR=''${OUT_DIR:-${fileLocation}}
    ${lib.concatMapStringsSep "\n" genBlock entries}
  '';
}

Faithfulness invariants (load-bearing): tri-state bail on mixed presence (do NOT weaken to "regenerate missing" — could overwrite a half-rotated set); secret read per-file when staging a dependency; deploy=false files staged to $in for dependents but mv gated on f.deploy so they never reach the target; lowercase in/out/prompts env (matches the documented $in/$out/$prompts). Plan normalization (attrset → ordered list records) lives in order/.mkPlan (toEntry), not backend-side.

Raw-flake drive (den-agnostic, end-to-end)

let
  genVars = import gen-vars { inherit (nixpkgs) lib; };
  gens = builtins.mapAttrs genVars.mkGenerator {
    a = { files."a" = { secret = true; }; script = ''echo a > "$out"/a''; };
    b = { dependencies = [ "a" ]; files."b" = { secret = true; }; script = ''cat "$in"/a/a > "$out"/b''; };
  };
  plan = genVars.mkPlan gens;
in
genVars.backends.onMachine { inherit pkgs plan; fileLocation = "/etc/vars"; }
# -> a generate-vars writeShellApplication. The library ran nothing.

4.4 The generation harness — emitter interface, source vs deploy, invocation

Generation is impure and runs outside evaluation. The pure library makes the plan; a backend emits a runnable harness that, when the user invokes it (never nix eval), runs generators in topo order, prompts, and writes outputs to a storage backend. This is agenix-rekey's model (flake apps generate/rekey, sandbox-relaxed) and clan-vars' (CLI). gen-vars runs nothing; it owns the contract + emits the artifact. The on-machine backend above is the reference harness instance.

Source vs deploy — two locations the contract separates (the on-machine reference deliberately collapses them):

  • Source store — the reproducible source of truth. agenix-rekey/clan-vars keep it in-repo, encrypted (master/admin key), committed: a value is generated once and reproduced across machines/rebuilds; prompts + randomness are captured here, once.
  • Deploy target — where a resolved value lands per consuming class (a host path / a tf var / a k8s Secret), derived from the source store (agenix additionally rekeys source → per-host key).

The on-machine backend uses its deploy target as its source (/etc/vars, regenerated per machine if missing) — fine for a single-host demo but not reproducible; the in-repo-encrypted backend is the canonical source-of-truth shape (follow-on).

Harness-emitter interface (pure; gen-vars owns it):

# A backend is:  plan -> harness
#   harness = {
#     app     : derivation;            # runnable (writeShellApplication / flake app): runs generators in
#                                      #   topo order, prompts, writes to `store`. Pure to BUILD, impure to RUN.
#     store   : storeRef;              # where source values live (backend-defined: a path, a sops fileset, ...)
#     resolve : handle -> resolution;  # how a CONSUMER reads a stored value back (the §2.4 resolver, from `store`)
#   }
mkHarness : { plan; store; emitApp; resolve } -> harness

gen-vars exposes mkHarness (the contract) and backends.onMachine (one instance). The resolver is the source↔consumer link — the same store the harness writes is what resolve reads, so generation and consumption stay consistent by construction. (In the gen/ demo, §5.6's per-class classResolvers are the resolve half; the chosen backend's store is what their paths/refs point at.)

Invocation surface. The harness app is surfaced as a flake app (nix run .#vars-generate), the explicit out-of-eval step and the only impure entry; evaluation (plan, handles, resolvers, the multi-target proof) never runs it.

Canonical follow-on (out of scope, interface-ready): in-repo-encrypted source store (age/sops). backends.inRepoAge { plan; recipients; sourceDir; } would emit an app that runs each generator, age-encrypts $out to recipients, and writes sourceDir/<gen>/<file>.age (committed); a deploy step decrypts per host (optionally rekeying, agenix-rekey style); its resolve points at the decrypted deploy path. It pulls in age/sops + an encrypt/commit flow, so it is not in the spike — but the mkHarness interface hosts it without change, confirming gen-vars stays the pure plan/contract layer while every impure storage policy is a swappable backend.


5. The gen/ integration demo (fresh, lean, scope-driven)

The gen-vars integration demo is a fresh, self-contained demo in the gen/ ecosystem hub (gen/examples/gen-vars/, establishing gen's first examples/ convention), not deltas on the gen-aspects demo. It imports gen-vars purely (mkHandle / mkGenerator / mkPlan / resolveAll / handleId); gen-vars stays unaware of den, gen-aspects, scope, and classes. Because the demo is fresh there is no composition.nix BLOCKER retrofit and no outputs.nix reader migration — every module is written correctly from the start.

It acquires every gen lib (incl gen-vars once added to the hub) in one inputs.gen.lib.mkGenLibs { inherit lib; } call, and composes four libs end-to-end:

  • gen-aspects — aspect schema with two registered classes (nixos + terranix), a generators aspectModule, flatten, and parametric class content.
  • gen-scope — a real small env/host parent graph that drives generator selection (the den value-add over flat nixpkgs vars: graph topology is the selection mechanism). The env baseline is load-bearing (it materializes a real generator and is proven by a discriminating assert), and a deliberately overlapping generator across tiers makes the set-union accumulation observable.
  • gen-bindgenBind.wrap injects a host-global vars binding into each aspect's class content.
  • gen-varsresolveAll fans one handle to multiple class-native values in one call (the multi-target headline, exercised with ≥2 resolvers on the proof path).

gen-derive is not on the selection path — a pure genScope.inheritAll parent-chain accumulator is strictly leaner and makes the scope graph the selection mechanism, which is the demo headline. The full Spike-5 settings cascade (foldLayersTraced, settings stratification, firewall/nginx/postgres aspects) is out of scope.

5.1 The BLOCKER, fixed from the start: classNames threaded explicitly

config.schema.aspect.classes does not exist — registered classes are consumed only internally at gen-aspects types.nix:104 (lib.genAttrs (builtins.attrNames (cnf.classes or { })) to build per-class deferredModule options) and are never re-surfaced on config. The demo threads the declared class set explicitly from the literal classes attrset via _module.args, with no silent or-fallback to nixos:

# setup.nix — the SINGLE source of truth for classNames.
classes = { nixos = { }; terranix = { }; };
# ...
config._module.args = {
  inherit classes;
  classNames = builtins.attrNames classes;   # consumed by injection.nix; NEVER config.schema.
};

Registering terranix is required: without it the terranix key falls through the aspect submodule freeform (lazyAttrsOf aspectType) and becomes a nested aspect instead of class content — silently wrong, and assembledClasses.<host>.vpn.terranix would be empty.

5.2 setup.nix — one mkGenLibs call, schema, threaded classNames

genLibs = inputs.gen.lib.mkGenLibs { inherit lib; } returns all libs under short names (aspects, scope, bind, vars, …); the demo aliases them (genVars = genLibs.vars). mkAspectSchema { inherit classes; … aspectModules = [ { options.generators = …; } ]; } registers both classes and the generators channel. imports = [ (aspectSchema.mkAspectModule { }) ]; options.schema = aspectSchema.schemaOption;. classes + classNames + the lib aliases are exported via _module.args.

Precondition (load-bearing, surfaced in flake.nix): the hub input MUST be named exactly gen (gen.url = "path:../.."), because setup.nix calls inputs.gen.lib.mkGenLibs. inputs is a flake-parts module arg, so setup.nix's header is { lib, inputs, ... }:.

# =============================================================================
# gen/examples/gen-vars/modules/setup.nix
# Acquire ALL gen libs (incl gen-vars) in ONE mkGenLibs call; declare the
# aspect schema with nixos + terranix classes and a `generators` aspectModule;
# thread classNames EXPLICITLY from the literal classes attrset.
# =============================================================================
{ lib, inputs, ... }:
let
  genLibs = inputs.gen.lib.mkGenLibs { inherit lib; };

  genAspects = genLibs.aspects;
  genScope = genLibs.scope;
  genBind = genLibs.bind;
  genVars = genLibs.vars;

  # The declared class set is the SINGLE SOURCE OF TRUTH for classNames.
  # config.schema.aspect.classes does NOT exist (classes live at cnf.classes
  # internally, never re-surfaced — gen-aspects types.nix:104). We thread the
  # literal set explicitly; NO silent or-fallback to nixos.
  classes = {
    nixos = { };
    terranix = { }; # the SECOND target class; required or `terranix` falls
    # through freeform and becomes a nested aspect.
  };

  aspectSchema = genAspects.mkAspectSchema {
    inherit classes;
    collections = {
      tags = { default = [ ]; };
    };
    aspectModules = [
      {
        # gen-vars generator declarations owned by this aspect.
        options.generators = lib.mkOption {
          type = lib.types.lazyAttrsOf lib.types.raw;
          default = { };
          description = "gen-vars generator declarations owned by this aspect.";
        };
      }
    ];
  };
in
{
  imports = [ (aspectSchema.mkAspectModule { }) ];

  options.schema = aspectSchema.schemaOption;

  config._module.args = {
    inherit
      genAspects
      genScope
      genBind
      genVars
      aspectSchema
      ;
    # The threaded class set + names. Read these, NEVER config.schema.
    inherit classes;
    classNames = builtins.attrNames classes;
  };
}

5.3 fleet.nix — hosts with role + env

# =============================================================================
# gen/examples/gen-vars/modules/fleet.nix
# Hosts with a role + env. env/role drive scope-graph generator selection.
# =============================================================================
{ lib, ... }:
{
  options.fleet.hosts = lib.mkOption {
    type = lib.types.attrsOf (lib.types.submodule {
      options = {
        role = lib.mkOption { type = lib.types.str; };
        env = lib.mkOption { type = lib.types.str; };
      };
    });
    default = { };
  };

  config.fleet.hosts = {
    # role == "vpn" selects wg-key + monitoring; env == "prod" adds tls-ca.
    vpn-host = {
      role = "vpn";
      env = "prod";
    };
    web-host = {
      role = "web";
      env = "prod";
    };
    dev-host = {
      role = "web";
      env = "dev";
    };
  };
}

5.4 scope.nix — a real env/host graph drives generator SELECTION (load-bearing env baseline + observable union)

A parent graph wires each host's parent to its env via the inverted genScope.edge "host:<h>" "env:<e>" (overlaid). genScope.buildNodes { parentGraph; types; decls; } puts a baseline generator set on each env node and a role-driven generator set on each host node. genScope.eval { roots; attributes = { children; imports; generatorsFor = …; }; } registers the selection attribute:

generatorsFor = genScope.inheritAll {
  extract = node: node.decls.generators or null;
  combine = a: b: lib.unique (a ++ b); # union up the env->host chain
};

generatorNamesForHost h = scopeEval.get "host:${h}" "generatorsFor" returns the host's generator names, merged up the chain — the env contributes a baseline, the host adds its own. This is genuinely topology-driven: the scope graph selects generators, not a flat per-host lookup.

Two changes make the graph load-bearing on the proven path (folded critique fixes):

  1. Env baseline is real, not a stub. prod contributes tls-ca (a declared generator, §5.8 aspects/tls.nix), so vpn-host receives tls-ca solely by env inheritance — asserted in §5.9 with a negative (tls-ca ∉ roleGenerators.vpn). Deleting the env tier would now fail the test.
  2. Union is observable. monitoring appears in both envGenerators.prod and roleGenerators.vpn, so lib.unique is exercised: vpn-host must get exactly one monitoring (not two). With the default a ++ b combine this assertion fails — proving set-union semantics, not coincidental non-overlap.
# =============================================================================
# gen/examples/gen-vars/modules/scope.nix
# A REAL small env/host parent graph + scope-driven generator SELECTION.
# The env node contributes a baseline generator set; each host adds its own
# (role-driven). `genScope.inheritAll` UNIONS the set up the env->host chain:
# the GRAPH TOPOLOGY is the selection mechanism (den value-add over flat vars).
# =============================================================================
{ lib, config, genScope, ... }:
let
  hosts = config.fleet.hosts;
  hostNames = builtins.attrNames hosts;
  envNames = lib.unique (lib.mapAttrsToList (_h: c: c.env) hosts);

  # role -> generator names. Selection is role-driven, composed up the chain.
  # `monitoring` deliberately overlaps with envGenerators.prod so lib.unique
  # is load-bearing (proves set-union, not coincidental non-overlap).
  roleGenerators = {
    vpn = [ "wg-key" "monitoring" ];
    web = [ ];
  };
  # env -> baseline generators every host in that env inherits (topology-driven).
  # `tls-ca` is a REAL declared generator (aspects/tls.nix) and reaches
  # vpn-host SOLELY by env inheritance — the discriminating proof of §5.9.
  envGenerators = {
    prod = [ "tls-ca" "monitoring" ];
    dev = [ ];
  };

  # P-edges: each host's parent is its env (inverted star = child->center).
  parentGraph = genScope.overlays (
    map (h: genScope.edge "host:${h}" "env:${hosts.${h}.env}") hostNames
  );

  roots = genScope.buildNodes {
    inherit parentGraph;
    types =
      lib.genAttrs (map (e: "env:${e}") envNames) (_: "env")
      // lib.genAttrs (map (h: "host:${h}") hostNames) (_: "host");
    decls =
      lib.listToAttrs (
        map (e: lib.nameValuePair "env:${e}" { generators = envGenerators.${e} or [ ]; }) envNames
      )
      // lib.listToAttrs (
        map (
          h:
          lib.nameValuePair "host:${h}" {
            role = hosts.${h}.role;
            env = hosts.${h}.env;
            generators = roleGenerators.${hosts.${h}.role} or [ ];
          }
        ) hostNames
      );
  };

  scopeEval = genScope.eval {
    inherit roots;
    attributes = {
      # Required by eval's attribute-set completeness / materialization helpers
      # (NOT for host resolution: buildNodes flattens every vertex into `roots`,
      # so `get` short-circuits on `rootEval ? id` and never walks children here).
      children = _self: id: lib.filterAttrs (_: n: n.parent == id) roots;
      imports = _self: _id: [ ];
      # THE SELECTION: union the host's own generators with every ancestor's
      # baseline set, up the env->host chain. lib.unique de-dups overlap.
      generatorsFor = genScope.inheritAll {
        extract = node: node.decls.generators or null;
        combine = a: b: lib.unique (a ++ b);
      };
    };
  };

  # The scope-driven answer: which generator NAMES this host gets.
  generatorNamesForHost = h: scopeEval.get "host:${h}" "generatorsFor";
in
{
  config._module.args = { inherit scopeEval generatorNamesForHost roleGenerators; };
}

Gotchas honored: genScope.edge/star is inverted (leaves→center = parent direction); a node may have at most one parent edge (P is a partial function — buildNodes strict=true deepSeqs validation and throws on 2+ parent edges, "P must be a partial function, Neron §2.2"; the one-edge-per-host construction is safe). inheritAll's default combine is list-concat, so we pass lib.unique. eval's get throws on an unregistered attribute name, so every queried attribute (generatorsFor, children) is registered. node.decls always carries an injected __edges key — extract reads a specific key (generators) so __edges is harmless, but any code iterating node.decls must exclude it. Corrected rationale: children is registered to satisfy eval's attribute completeness and materialization walkers, not because host resolution needs it — every vertex is a root here, so resolution is a direct rootEval lookup; parseParent is therefore omitted (it would be dead on this path).

5.5 generators.nix — the generation pass (gen-vars, imported-only)

flat = genAspects.flatten config.aspects walks the aspect tree; a fold over flat.<path>.generators builds a host-global allGeneratorDecls registry (erroring on conflicting redeclaration). Per host, only the scope-selected generators that have a declaration are materialized into pure handles. A scope-selected name with no declaration (e.g. monitoring, an env/role baseline left unauthored as a generator) is filtered out before mkHandle/mkGenerator, so it never throws on a missing .files:

handlesForHost = h:
  lib.genAttrs
    (builtins.filter (gn: allGeneratorDecls ? ${gn}) (generatorNamesForHost h))
    (gen:
      lib.mapAttrs (fileName: fileSpec:
        genVars.mkHandle { generator = gen; name = fileName; secret = fileSpec.secret or true; }
      ) (allGeneratorDecls.${gen}.files or { }));

generatorsForHost ({ <host> = { <gen> = { <file> = handle; }; }; }) and generatorPlans (genVars.mkPlan per host) are exported.

# =============================================================================
# gen/examples/gen-vars/modules/generators.nix
# Collect generator declarations from the aspect tree (flatten); per host,
# instantiate ONLY the scope-selected, DECLARED generators into pure gen-vars
# handles + a plan. gen-vars is imported-only here.
# =============================================================================
{ lib, config, genAspects, genVars, generatorNamesForHost, ... }:
let
  flat = genAspects.flatten config.aspects;
  hostNames = builtins.attrNames config.fleet.hosts;

  # Fold every aspect's `generators` into one host-global declaration registry,
  # erroring on conflicting redeclaration of the same generator name.
  allGeneratorDecls = builtins.foldl' (
    acc: path:
    let decls = flat.${path}.generators or { }; in
    builtins.foldl' (
      a: gn:
      if (a ? ${gn}) && a.${gn} != decls.${gn} then
        throw "gen-vars-demo: conflicting generator declarations for '${gn}'"
      else
        a // { ${gn} = decls.${gn}; }
    ) acc (builtins.attrNames decls)
  ) { } (builtins.attrNames flat);

  # A scope-selected name with no declaration (e.g. an unauthored baseline) is
  # skipped, not a throw on `allGeneratorDecls.<gn>.files`.
  selectedDeclared = h: builtins.filter (gn: allGeneratorDecls ? ${gn}) (generatorNamesForHost h);

  handlesForHost = h:
    lib.genAttrs (selectedDeclared h) (gen:
      lib.mapAttrs (fileName: fileSpec:
        genVars.mkHandle { generator = gen; name = fileName; secret = fileSpec.secret or true; }
      ) (allGeneratorDecls.${gen}.files or { }));

  generatorsForHost = lib.genAttrs hostNames handlesForHost;

  planForHost = h:
    genVars.mkPlan (lib.genAttrs (selectedDeclared h) (g: genVars.mkGenerator g allGeneratorDecls.${g}));
in
{
  config._module.args = {
    inherit generatorsForHost allGeneratorDecls;
    generatorPlans = lib.genAttrs hostNames planForHost;
  };
}

5.6 resolvers.nix — per-class registry + projectVars (via resolveAll, host-aware)

A central classResolvers registry maps each class name to a handle -> classNativeValue resolver. To honor the scope-driven generation framing (not just selection), the nixos resolver threads host into the path so two hosts get distinct paths for the same generator; the terranix resolver yields a per-generator/file ref. Resolution runs here, before genBind.wrap, through the gen-vars core interface resolveAll (never a bypass), with the single class projected:

# =============================================================================
# gen/examples/gen-vars/modules/resolvers.nix
# Per-class resolver registry + projectVars. Resolution runs through the
# gen-vars CORE interface resolveAll (never a bypass). The nixos resolver is
# host-aware so generation varies by scope position, not just selection.
# =============================================================================
{ lib, genVars, ... }:
let
  varRoot = "/etc/vars";
  # handle -> class-native value. `host` is threaded so the GENERATED path
  # varies by scope position (vpn-host vs web-host differ for the same gen).
  classResolvers = {
    nixos = host: handle:
      let sub = if handle.secret then "secret" else "public";
      in "${varRoot}/${host}/${sub}/${handle.generator}/${handle.name}";
    terranix = _host: handle:
      "\${data.vars_file.${handle.generator}_${handle.name}.content}";
  };

  # Resolve every selected handle for one host into one class's native values.
  # Uses resolveAll (the multi-target core) then projects the single class.
  projectVars = host: className: hostHandles:
    lib.mapAttrs (_gen: files:
      lib.mapAttrs (_file: handle:
        (genVars.resolveAll { ${className} = classResolvers.${className} host; } handle).${className}
      ) files
    ) hostHandles;
in
{
  config._module.args = { inherit classResolvers projectVars varRoot; };
}

5.7 injection.nix — per-class loop + host-global vars binding

A per-class loop (over the threaded classNames, never config.schema) wraps each aspect's class content with genBind.wrap, binding a vars argument (resolved, class-native values — never handles) alongside host:

# =============================================================================
# gen/examples/gen-vars/modules/injection.nix
# Per-class loop: bind host-global `vars` (resolved, class-native values) into
# each aspect's class content via genBind.wrap. classNames is threaded
# EXPLICITLY (NEVER off config.schema).
# =============================================================================
{ lib, config, genAspects, genBind, classNames, generatorsForHost, projectVars, ... }:
let
  flat = genAspects.flatten config.aspects;
  leafName = path: lib.last (lib.splitString "/" path);
  hostNames = builtins.attrNames config.fleet.hosts;

  injectAspectClass = { host, aspectLeaf, className, classContent }:
    (genBind.wrap {
      module = classContent;
      bindings = {
        host = { name = host; } // (config.fleet.hosts.${host} or { });
        # Resolved, class-native var values (strings/refs — NEVER handles).
        # host-global: any aspect's class fn on this host can read any handle.
        vars = projectVars host className (generatorsForHost.${host} or { });
      };
      contracts.vars = genBind.contract.isType "set";
      provenance.vars = {
        source = "gen-vars";
        scope = "host:${host}/class:${className}";
      };
    }).module;

  assembleHostAspects = host:
    lib.mapAttrs' (path: aspect:
      lib.nameValuePair (leafName path)
        # classNames is the literal declared set (NOT config.schema).
        (lib.genAttrs classNames (className:
          injectAspectClass {
            inherit host className;
            aspectLeaf = leafName path;
            classContent = aspect.${className} or { };
          }))
    ) flat;

  assembledClasses = lib.genAttrs hostNames assembleHostAspects;
in
{
  config._module.args = { inherit injectAspectClass assembledClasses; };
}

vars rides the identical deferredModule seam settings rides in the proven path: a parametric { vars, ... }: class arrives coerced to a two-level { imports = [ { _file = …; imports = [ <fn{vars}> ]; } ]; }, and genBind.wrapImportsModule recurses (via wrapCore's recursive re-dispatch) until wrapFunctionModule binds the named arg. genBind.wrap binds only args the class fn names (wrap.nix:88-90) — a static attrset never names vars, so it is a deliberate no-bind passthrough. vars is host-global (keyed vars.<generator>.<file>), deliberately unlike per-aspect settings: any aspect's class fn on a host can read any of that host's handles. assembledClasses is { <host> = { <aspectLeaf> = { <className> = wrappedModule; }; }; }.

5.8 aspects/vpn.nix + aspects/tls.nix — the proof aspect and the env-baseline aspect

The vpn aspect declares wg-key and reads vars.wg-key."public.key" from both classes (the SAME handle → two classes). The tls aspect declares tls-ca so the env-baseline selection (§5.4) materializes a real handle, making the env tier load-bearing:

# =============================================================================
# gen/examples/gen-vars/modules/aspects/vpn.nix
# The proof aspect: a wg-key generator + PARAMETRIC nixos/terranix classes that
# read the host-global `vars` binding. SAME handle (wg-key/public.key) consumed
# by BOTH classes — the multi-target headline.
# =============================================================================
{ ... }:
{
  config.aspects.vpn = {
    tags = [ "network" "security" ];

    generators.wg-key = {
      files."private.key" = { secret = true; };
      files."public.key" = { secret = false; };
      runtimeInputs = [ ]; # wireguard-tools in a real build
      script = ''wg genkey | tee "$out"/private.key | wg pubkey > "$out"/public.key'';
    };

    # PARAMETRIC classes (MUST name `vars`): a static attrset never names
    # `vars`, so genBind.wrap (wrap.nix:88-90 binds only named args) would never
    # bind it and `vars.wg-key."public.key"` would be a missing-attr throw.
    # The SAME handle reaches both classes via two resolvers.
    nixos = { vars, ... }: {
      networking.wireguard.interfaces.wg0.publicKey = vars.wg-key."public.key";
    };
    terranix = { vars, ... }: {
      resource.wireguard_peer.self.public_key = vars.wg-key."public.key";
    };
  };
}
# =============================================================================
# gen/examples/gen-vars/modules/aspects/tls.nix
# The env-baseline aspect: declares the `tls-ca` generator that prod-env hosts
# inherit via the scope graph (NOT via their role). Makes the env tier
# load-bearing — see the §5.9 negative assert (tls-ca reaches vpn-host SOLELY
# by env inheritance).
# =============================================================================
{ ... }:
{
  config.aspects.tls = {
    tags = [ "security" ];
    generators.tls-ca = {
      files."cert.pem" = { secret = false; };
      files."key.pem" = { secret = true; };
      script = ''step-ca-init > "$out"/cert.pem'';
    };
  };
}

Both vpn classes must be parametric { vars, ... }:. The deferredModule coercion is two imports-levels deep ({ imports = [ { _file; imports = [ fn ]; } ] }); the seam relies on wrapCore's recursive re-dispatch reaching the lambda at level 2. It works today and is pinned by the §5.9 CI boolean; a future gen-bind flattening of wrapImportsModule to one level would break it silently (vars unbound → missing-attr throw), so the contract is recorded here.

5.9 outputs.nix — the multi-target proof (the spike headline)

The primary, discriminating proof calls genVars.resolveAll once with both resolvers on the one handle — exercising the defining fan-out (mapAttrs over ≥2 resolvers in one call), not two degenerate single-resolver calls. The end-to-end assembledClasses eval (two lib.evalModules) is kept as a secondary integration check that the wrapped/injected classes consume the same handle:

# =============================================================================
# gen/examples/gen-vars/modules/outputs.nix
# THE MULTI-TARGET PROOF. Primary: ONE resolveAll call with BOTH resolvers on
# ONE handle (the defining fan-out). Secondary: end-to-end injected-class eval.
# Plus the discriminating SCOPE asserts (env baseline + union).
# =============================================================================
{
  lib,
  genVars,
  classResolvers,
  generatorsForHost,
  generatorNamesForHost,
  roleGenerators,
  assembledClasses,
  varRoot,
  ...
}:
let
  handle = generatorsForHost.vpn-host.wg-key."public.key"; # the ONE handle

  # --- PRIMARY: one resolveAll, BOTH resolvers, one eval (the headline) ---
  targets = genVars.resolveAll {
    nixos = classResolvers.nixos "vpn-host";
    terranix = classResolvers.terranix "vpn-host";
  } handle;

  multiResolveProof =
    genVars.handleId handle == "wg-key/public.key"
    && targets.nixos == "${varRoot}/vpn-host/public/wg-key/public.key"
    && lib.hasInfix "vars_file.wg-key_public.key" targets.terranix;

  # --- SECONDARY: end-to-end through the injected, wrapped classes ---
  evalNixos =
    (lib.evalModules {
      modules = [
        {
          options.networking.wireguard.interfaces = lib.mkOption {
            type = lib.types.attrsOf (lib.types.submodule {
              options.publicKey = lib.mkOption { type = lib.types.str; };
            });
            default = { };
          };
        }
        assembledClasses.vpn-host.vpn.nixos
      ];
    }).config.networking.wireguard.interfaces.wg0.publicKey;

  evalTf =
    (lib.evalModules {
      modules = [
        { freeformType = lib.types.lazyAttrsOf lib.types.raw; }
        assembledClasses.vpn-host.vpn.terranix
      ];
    }).config.resource.wireguard_peer.self.public_key;

  endToEndProof =
    evalNixos == "${varRoot}/vpn-host/public/wg-key/public.key"
    && lib.hasInfix "vars_file.wg-key_public.key" evalTf;

  # --- SCOPE asserts: the graph is load-bearing, not a stub ---
  vpnGens = generatorNamesForHost "vpn-host";
  # tls-ca reaches vpn-host SOLELY by env inheritance (NOT its role).
  envBaselineProof =
    builtins.elem "tls-ca" vpnGens && !(builtins.elem "tls-ca" roleGenerators.vpn);
  # union is real: monitoring is in BOTH tiers; vpn-host gets exactly ONE.
  unionProof = builtins.length (builtins.filter (g: g == "monitoring") vpnGens) == 1;

  reachesTwoClasses = multiResolveProof && endToEndProof && envBaselineProof && unionProof;
in
{
  flake.varsMultiTarget = {
    handleId = genVars.handleId handle; # "wg-key/public.key"
    nixosPath = targets.nixos; # "/etc/vars/vpn-host/public/wg-key/public.key"
    terranixRef = targets.terranix; # "${data.vars_file.wg-key_public.key.content}"
    inherit
      multiResolveProof
      endToEndProof
      envBaselineProof
      unionProof
      reachesTwoClasses
      ;
  };
}

The test surfacing is done in a dedicated CI test module (§6), keeping app modules free of the CI flake.tests option schema:

flake.tests.gen-vars = {
  multiTarget = { expr = demo.varsMultiTarget.reachesTwoClasses; expected = true; };
  multiResolve = { expr = demo.varsMultiTarget.multiResolveProof; expected = true; };
  envBaseline = { expr = demo.varsMultiTarget.envBaselineProof; expected = true; };
  union = { expr = demo.varsMultiTarget.unionProof; expected = true; };
};

so CI fails loudly if one handle stops reaching two classes through resolveAll, or if the scope graph stops contributing the env baseline / collapses the union.

5.10 demo data model

classes (literal)     : { nixos = {}; terranix = {}; }              # SINGLE source of classNames
classNames            : builtins.attrNames classes                  # threaded via _module.args; NEVER config.schema
generatorNamesForHost : host -> [genName]                           # scope-graph SELECTION (inheritAll up env->host, lib.unique)
generatorsForHost     : { <host> = { <gen> = { <file> = handle; }; }; }   # declared+selected only
classResolvers        : { <className> = host -> handle -> classNativeValue; }   # host-aware (scope-driven generation)
projectVars h c       : { <gen> = { <file> = classNativeValue; }; } # via resolveAll (core interface)
assembledClasses      : { <host> = { <aspectLeaf> = { <className> = wrappedModule; }; }; }
generatorPlans        : { <host> = plan; }

`vars` is host-global (keyed by generator), deliberately unlike per-aspect `settings`.
gen-vars stays imported-only: mkHandle / mkGenerator / mkPlan / resolveAll / handleId.
The headline `resolveAll { nixos; terranix; } handle` runs ONCE with ≥2 resolvers (multiResolveProof).
The scope graph is load-bearing: tls-ca (env-only) + monitoring (union) gate the boolean.

6. Repo / file layout + testing

The gen-vars library repo layout (sections 1–4: pure/, order/, module/, backend/) is unchanged. This section covers only the den-adapter / demo half, which now lives in the gen/ hub as gen/examples/gen-vars/ plus three gen/ root deltas.

gen-vars library repo layout + root default.nix

gen-vars/
  flake.nix              # functor shape (see §6.0): outputs.lib = import ./. {...}; __functor = _: import ./.
  default.nix            # { lib ? null, inputs ? {} }: pure // (order/module/backends when lib)
  pure/                  # LIB-FREE  (default.nix zero-arg aggregator; generator.nix; handle.nix; resolve.nix)
  order/                 # LIB + optional genGraph  (default.nix: mkPlan / depGraph)
  module/                # LIB + module system  (default.nix aggregator §3.3; file-module.nix; registry.nix; nixpkgs-vars-bridge.md)
  backend/on-machine.nix # { lib }: { pkgs, plan, fileLocation }: writeShellApplication (emits, runs nothing)
  examples/raw-flake/    # den-agnostic end-to-end driver (§4)
  ci/                    # flake.nix (gen.lib.mkCi); flake.lock (pins gen + gen-graph); tests/{generator,handle,resolve,plan}.nix
  README.md / LICENSE / .envrc / .gitignore

Root default.nix (resolves the optional genGraph functor input into order/'s genGraph arg; pure/ stays usable with lib == null):

{ lib ? null, inputs ? { } }:
let
  pure = import ./pure;                                   # zero-arg, lib-free
  # genGraph is OPTIONAL order/ enrichment: call the functor flake input when present;
  # order/ ordering + cycle detection always run on lib.toposort, so null is fine.
  genGraph =
    if lib == null then null
    else if inputs ? gen-graph then inputs.gen-graph { inherit lib; }
    else null;
  order    = if lib != null then import ./order  { inherit lib genGraph; } else null;
  module   = if lib != null then import ./module { inherit lib; } else null;
  backends = if lib != null then { onMachine = import ./backend/on-machine.nix { inherit lib; }; } else null;
in
{ inherit pure; } // pure
  // (if order   != null then order   else { })
  // (if module  != null then module  else { })
  // (if backends != null then { inherit backends; } else { })

6.0 gen-vars flake.nix shape — a HARD precondition for the hub wiring

gen-vars MUST ship a flake whose outputs exposes a top-level __functor mirroring gen-graph exactly, so the hub can call it genInputs.gen-vars { … }:

# gen-vars/flake.nix (REQUIRED shape — mirrors gen-graph/flake.nix verbatim)
{
  description = "gen-vars: scope-driven, multi-target variable generation";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  inputs.gen-graph.url = "github:sini/gen-graph"; # optional order/ enrichment
  outputs =
    { nixpkgs, gen-graph, ... }:
    {
      lib = import ./. {
        lib = nixpkgs.lib;
        inputs = { inherit gen-graph; };
      };
      __functor = _: import ./.;
    };
}

with the root default.nix signature { lib ? null, inputs ? { } }:. Until gen-vars is published this shape is unverifiable (github:sini/gen-vars does not exist yet). The hub wiring below is therefore written defensively so it evaluates whether gen-vars ships functor-style (like gen-graph) or import-path-style (like gen-bind/gen-schema), removing the one unverified dependency from the critical path.

6.1 gen/ root deltas (add gen-vars to the hub)

Two required edits + one optional:

  1. gen/flake.nix — add the input alongside the other 8 gen libs:

    gen-vars.url = "github:sini/gen-vars";
  2. gen/lib/mkGenLibs.nix — instantiate gen-vars defensively (functor-or-path), threading gen-graph from genInputs (captured stage 1) so order/'s optional genGraph enrichment reuses the hub's pin (no revision skew):

    # gen-vars: functor (gen-graph style) OR import-path (gen-bind style).
    # Defensive call removes the unverified-shape dependency before publish.
    vars =
      let f = genInputs.gen-vars;
      in (if builtins.isFunction f then f else import "${f}")
         { inherit lib; inputs = { inherit (genInputs) gen-graph; }; };

    then add vars to the returned inherit list (algebra schema aspects scope graph select bind derive vars).

    Real-code note: the existing hub uses both styles — functor for gen-algebra/gen-scope/gen-graph (genInputs.gen-X { inherit lib; }) and import-path for gen-bind/gen-schema/gen-aspects/gen-select/gen-derive (import "${genInputs.gen-X}/…" { … }). The defensive form above is correct under either, so the wiring is not hostage to gen-vars' final flake shape. (When the shape is confirmed post-publish, it may be simplified to the matching one-liner.)

  3. (OPTIONAL) gen/flakeModules/genLibs.nix — for flake-parts consumers of flakeModules.genLibs, add genVars = genLibs.vars; to _module.args for parity. The fresh demo does not need this (it calls mkGenLibs directly and reads genLibs.vars).

    _module.args = {
      genAlgebra = genLibs.algebra;
      genSchema = genLibs.schema;
      genAspects = genLibs.aspects;
      genScope = genLibs.scope;
      genGraph = genLibs.graph;
      genSelect = genLibs.select;
      genBind = genLibs.bind;
      genDerive = genLibs.derive;
      genVars = genLibs.vars; # <-- NEW
    };

gen/flake.lock gets nix flake update gen-vars once gen-vars is published. During dev, pin gen-vars.url = "path:/abs/path/to/gen-vars" so the functor-vs-path shape is verified before merge. Authoring is unblocked now; evaluation is gated on publish.

6.2 gen/examples/gen-vars/ — NEW (gen's first examples/ dir; app/test split)

The demo adopts the proven two-flake split (clean app vs CI separation; matches every gen lib): the app flake emits flake.varsMultiTarget under a plain flake-parts.lib.mkFlake { inherit inputs; }, and a sibling ci/ flake uses gen.lib.mkCi with a dedicated ci/tests/ dir that imports the app output and asserts on it. This keeps the app modules out of the CI flake.tests option schema and avoids dragging the whole demo eval under every nix-unit run more than necessary.

gen/examples/gen-vars/
  flake.nix              # self-contained app flake:
                         #   inputs = { gen.url = "path:../.."; nixpkgs; flake-parts; import-tree; };
                         #   flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";  (lock hygiene)
                         #   outputs = inputs:
                         #     inputs.flake-parts.lib.mkFlake { inherit inputs; }   # NOT { } — needs inputs
                         #       (inputs.import-tree ./modules);
                         #   The hub input MUST be named `gen` (setup.nix reads inputs.gen.lib.mkGenLibs).
  modules/
    setup.nix            # ONE mkGenLibs call -> all libs incl genVars; aspect schema { nixos; terranix; } +
                         #   generators aspectModule; threads classes + classNames via _module.args.
    fleet.nix            # fleet.hosts option + 3 hosts (role + env).
    scope.nix            # env/host parent graph + inheritAll generator SELECTION (lib.unique union);
                         #   exports generatorNamesForHost + roleGenerators. children rationale corrected;
                         #   parseParent omitted (dead on this all-roots path).
    generators.nix       # flatten -> allGeneratorDecls; per-host DECLARED+selected handles + plans (gen-vars).
    resolvers.nix        # classResolvers (host-aware) + projectVars via resolveAll; exports classResolvers, varRoot.
    injection.nix        # per-class loop; genBind.wrap binds host-global vars; classNames threaded explicitly.
    aspects/
      vpn.nix            # wg-key generator + parametric nixos/terranix classes (read vars) — the proof aspect.
      tls.nix            # tls-ca generator — the env-baseline aspect (load-bearing env tier).
    outputs.nix          # flake.varsMultiTarget: multiResolveProof (PRIMARY, resolveAll w/ both resolvers) +
                         #   endToEndProof + envBaselineProof + unionProof + reachesTwoClasses.
  ci/
    flake.nix            # gen.lib.mkCi { inherit inputs; name = "gen-vars-demo"; testModules = ./tests; }
                         #   -> reuses gen's shared treefmt/devshell + the flake.tests `expr == expected` contract.
                         #   inputs.gen.url = "path:../.."; inputs.demo.url = "path:..";
    tests/
      multi-target.nix   # imports the app flake's varsMultiTarget output (inputs.demo) and asserts:
                         #   flake.tests.gen-vars.{ multiTarget; multiResolve; envBaseline; union } = {expr;expected=true;}

Notes:

  • gen.url = "path:../.." keeps the demo self-contained: it inherits the hub's own gen-* lock (incl gen-vars), so no per-lib inputs are duplicated. The hub input is named exactly gen so inputs.gen.lib.mkGenLibs resolves.
  • mkFlake { inherit inputs; } is mandatory — the proven gen-aspects demo uses exactly this; passing { } omits inputs and setup.nix's inputs.gen.lib.mkGenLibs would see an undefined inputs.
  • import-tree is the module-tree owner; the hub already pins github:sini/import-tree, so the demo follows the hub's pin (no second import-tree revision).
  • The CI flake's tests/ reads the app flake's output (inputs.demo), so the heavy demo eval (two evalModules, the scope graph, all wrap calls) runs once inside the app flake and the CI flake only asserts booleans — lighter and more separable than making the app modules double as test modules.

6.3 Testing strategy

The headline is one discriminating boolean, reachesTwoClasses, decomposed into four CI asserts so a regression points at its cause:

  • multiResolvethe spike headline: one genVars.resolveAll { nixos; terranix; } call fans the single wg-key/public.key handle to a nixos path and a terranix ref in one eval (≥2 resolvers, mapAttrs fan-out — the den value-add over flat nixpkgs vars). Fails if a handle stops reaching two classes through the core interface.
  • multiTarget (== reachesTwoClasses) — the conjunction; also covers the end-to-end injected-class eval (the deferredModule two-level vars seam).
  • envBaseline — the scope graph is load-bearing: tls-ca reaches vpn-host solely by env inheritance, not its role.
  • unioninheritAll accumulation is a real set-union: monitoring (in both tiers) appears exactly once.

Two ergonomic entry points:

  • nix-unit --flake ./ci#tests (or ci / ci gen-vars.multiResolve in the devshell, via the shared ci/flakeModule.nix expr == expected contract) — CI fails loudly on any of the four regressions.
  • nix eval .#varsMultiTarget.reachesTwoClassestrue, plus .handleId / .nixosPath / .terranixRef and the four sub-proofs for inspection.

7. Risks / open questions / out-of-scope

Resolved this design (adopted defaults confirmed): generator declaration via generators aspectModule + provide-generator policy selection (no env/host generator-set cascade); central classResolvers registry (per-class-declaration is a follow-up); host-global vars; gen-graph optional in order/.

Out of scope (this spike):

  • Real encryption backends / the in-repo-encrypted source harness (backends.inRepoAge, age/sops) — the spike ships the on-machine reference harness only; the canonical reproducible source-of-truth store (§4.4) is fully specified but implemented as a follow-on backend/*.nix over the same plan + mkHarness interface (pulls in age/sops + an encrypt/commit flow).
  • Prompts during activation — the plan carries prompt metadata; when/how prompts run is the backend/consumer's run-time job (the upstream PR's open initrd/stage1 + prompt-during-activation problem; den's outside-evalModules processing is well-placed to help later).
  • gen-graph topo gaps — gen-graph has no ordering; lib.toposort is the source. If gen-graph grows toposort, order/ swaps its fallback with no consumer change.
  • Promotion of injectAspectClass/assembleHostAspects into gen-aspects.lib.* — when promoted, the per-class loop + vars binding move with it; classResolvers + the generation pass stay den/demo-side (they are den-aware).
  • Real secret-at-rest semantics in the demo — the proof asserts resolved paths/refs, not encrypted material.

Follow-ups (separate work):

  • Move the existing gen-aspects demo into gen/. The proven all-8-libs demo currently lives in its own repo at gen-aspects/examples/demo/ with 8 duplicated per-lib inputs. Relocate it to gen/examples/gen-aspects/ and collapse its inputs to the single gen.url = "path:../.." (reading libs via inputs.gen.lib.mkGenLibs), matching the gen-vars demo convention this spec establishes. This also makes gen/examples/ the canonical home for ecosystem demos (gen had none before gen-vars).
  • A simpler gen-aspects self-test demo. Alongside the relocated full demo, add a minimal gen/examples/gen-aspects/ (or examples/aspects-min/) that exercises just aspect schema + classes + flatten — a smoke test that does not pull scope/bind/derive — so gen-aspects has a lean, fast-evaluating example independent of the heavier integration demos.
  • (Optional, deferred — not blocking) Promote injectAspectClass / assembleHostAspects into gen-aspects.lib.* if a second consumer appears; the per-class loop + vars binding move with them, while classResolvers + the generation pass stay consumer-side (they are consumer-aware). The fresh demo keeps them demo-local for now.

Dismissed critiques: gen-graph "7 files" (it's 7 incl. default.nix; the load-bearing "no toposort" stands); wrap recursion depth (mechanism sound — identical to the proven settings path); multi-target core soundness (affirmative — resolveAll genuinely feeds one handle to N resolvers; clean Option-1/Option-3 split).

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