Skip to content

Latest commit

 

History

222 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gen-schema — typed record registry for Nix

CI License: MIT Sponsor

A typed record registry for Nix with extension points, strict validation, refinement contracts, identity hashing, cross-instance references, first-class mixins, introspection, and declarative methods. Built on the pure-gen module system (gen-merge — a byte-mode replacement for lib.evalModules + lib.types), with no nixpkgs.lib dependency.

gen-schema gives you what lib.types.submodule doesn't: open kind definitions that any module can extend, strict-by-default validation that catches typos immediately, refinement contracts co-located with type declarations, stable identity comparison via id_hash, cross-registry references that resolve to instances, reusable mixins with structural compatibility, and auto-generated documentation from your schema.

Dependency class: pure-gen. gen-schema runs on the pure-gen stack — gen-prelude (the pure utility base), gen-merge (the byte-mode module engine that REPLACES lib.evalModules + lib.types), and gen-algebra (the pure record algebra). It carries no nixpkgs.lib dependency; the constructor takes { prelude, merge, algebra }, auto-fetched from gen-schema's own lock (pass any explicitly to override). The module-system constructors it exports (identity hashing, strict rejection, validators) are gen-schema-owned — they relocated here from gen-algebra on 2026-06-26, leaving gen-algebra fully pure.

Table of Contents

Terminology

Term Definition
Kinds Schema-level type declarations (deferred modules defining options and config)
Instances Concrete values of a kind, evaluated through registries
Collections Named multi-contributor aggregation points with a merge strategy
Refs Cross-registry references between kinds (deferred or direct)
Edges Parent (P) nesting and ref (I) import relationships, exposed via _edges introspection

Overview

The mental model has two layers. A kind is a schema-level type — a deferred NixOS module declaring options, config defaults, methods, and collections. Kinds are open: any module (including one from a downstream flake input) can extend a kind by contributing more config.schema.<name> fragments, which merge through the module system. An instance is a concrete value of a kind, materialized through a registry (mkInstanceRegistry) that stamps each instance with a name, a stable id_hash, strict-key rejection, and any bound cross-instance references.

The authoring surface is small — most schemas are built from these constructors:

Constructor Role
mkSchemaOption Declares the schema option (holds all kinds; carries strict, baseModule, collections, computed settings)
mkInstanceRegistry Turns a kind into an attrsOf registry of instances, with refs, derive, and validator pipeline
mkInstanceType The single-instance submodule type (identity + strict injected), used by registries
ref / setOf / toSet Cross-instance references (deferred or direct) and identity-deduplicated collections
schemaFn Declarative methods on a kind, with named args auto-resolved from instance config
mkValidator / mkFieldValidator Cross-field constraints that travel with a kind and fire on every registry
refined / blame / mkMixin Refinement contracts, blame records, and first-class mixin fragments
mkCodec / renderDocs Serialization round-trips and markdown reference generation

Everything above the instance layer is pure schema — no validation or hashing happens at the kind level, which is what lets kinds compose via imports without duplicate-module conflicts. Instances are where the infrastructure (strict rejection, id_hash, ref binding, derive) is injected. Registries expose flat _-prefixed introspection (_kindNames, _topology, _edges, _refEdges, _roots, _leaves, _collectionKeys) that consumers read to build whatever graph format their evaluator needs.

Gen Ecosystem

Library Role
gen-prelude Pure nixpkgs-lib-free utility base (builtins re-exports + vendored lib utils)
gen-algebra Pure primitives (record, search monad, either, intensional identity)
gen-types Clean-room MIT structural type checker (leaf/poly checkers; verify: v → null|err)
gen-merge Byte-mode module merge engine (evalModuleTree, byte-identical to nixpkgs lib.evalModules over the priority subset)
gen-schema This lib — Typed registries (kinds, instances, collections, refs); re-hosted on gen-merge
gen-aspects Aspect type system (traits, classification, dispatch); re-hosted on gen-merge
gen-scope HOAG scope-graph evaluator (demand-driven, _eval memoization, circular attributes)
gen-graph Accessor-based graph query combinators (traversal, condensation, phaseOrder)
gen-select Selector algebra (pattern matching over graph positions)
gen-bind Module binding (inject external args into NixOS modules)
gen-dispatch Relational rule dispatch STEP (stratified phases, conflict resolution)
gen-memo The incremental plane — decides reuse, never evaluates (change propagation, AFFECTED set)
gen-vars Pure-Nix vars/secrets (den-agnostic)

Quick Start

As a flake-parts module

# flake.nix
{
  inputs.gen-schema.url = "github:sini/gen-schema";
  inputs.flake-parts.url = "github:hercules-ci/flake-parts";

  outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } {
    imports = [ inputs.gen-schema.flakeModules.default ];

    # Define a kind
    schema.host = {
      options.addr = lib.mkOption { type = lib.types.str; };
      options.role = lib.mkOption { type = lib.types.str; default = "worker"; };
    };

    # Create a registry and instances
    options.hosts = genSchema.mkInstanceRegistry config.schema.host {};
    hosts.igloo = { addr = "10.0.1.1"; role = "web"; };
    hosts.iceberg = { addr = "10.0.2.1"; };  # role defaults to "worker"

    # Use them
    flake.fleet = {
      iglooAddr = config.hosts.igloo.addr;     # → "10.0.1.1"
      iglooHash = config.hosts.igloo.id_hash;  # → "host:<deterministic SHA-256>"
    };
  };
}

The flake-parts module provides schema and genSchema with default settings (strict = true, no baseModule). For custom strict, baseModule, collections, or computed settings, use the programmatic API instead.

Programmatic use

# Without flake-parts — call the library directly. The module-system `lib` is gen-merge
# (it REPLACES lib.evalModules + lib.types); gen-schema itself is `gen-schema.lib`.
# Module-system vocabulary comes from gen-merge, reached through the hub as `gen.lib.merge` —
# gen-schema does not carry a copy of it. You still never reach for nixpkgs `lib`; the promise
# is unchanged and only the path is. A verbatim copy would be a SECOND build of the same names,
# and two gen libraries at different pins would then disagree on a type's identity while
# agreeing on its name (ADR-0014: the boundary is the eval, not the repo).
let
  genSchema = gen-schema.lib;
  merge = gen-merge.lib;   # evalModuleTree + mkOption + types — the pure-gen module system
in
merge.evalModuleTree {
  modules = [{
    options.schema = genSchema.mkSchemaOption {};
    config.schema.host.options.addr = merge.mkOption { type = merge.types.str; };
  }];
}

Without flakes

let
  # the root default.nix auto-fetches gen-prelude/gen-types/gen-merge/gen-algebra from gen-schema's own
  # flake.lock (content-addressed, in lockstep with the flake output). Pass { prelude; merge; algebra; }
  # to override — e.g. local checkouts.
  genSchema = import ./path/to/gen-schema { };
in
# use genSchema.mkSchemaOption, genSchema.mkInstanceRegistry, etc.

Use Cases

Plugin system — extensible application config

A base application defines its schema. Plugins extend it from external flake inputs without touching the base:

# Base app — defines the plugin kind
config.schema.plugin = {
  options.enabled = lib.mkOption { type = lib.types.bool; default = true; };
  options.priority = lib.mkOption { type = lib.types.int; default = 50; };
};

# Logging plugin (separate flake input) — extends the kind
config.schema.plugin.options.logLevel = lib.mkOption {
  type = lib.types.enum [ "debug" "info" "warn" "error" ];
  default = "info";
};

# Metrics plugin (another flake input) — extends the same kind
config.schema.plugin.options.metricsEndpoint = lib.mkOption {
  type = lib.types.nullOr lib.types.str;
  default = null;
};

# Instances — validated against the merged schema from all inputs
config.plugins.logging = { logLevel = "debug"; priority = 10; };
config.plugins.metrics = { metricsEndpoint = "/metrics"; };
config.plugins.logging.badOption = "x";  # → STRICT MODE error with fix guidance

Microservice registry — services referencing each other

config.schema.service = {
  options.port = lib.mkOption { type = lib.types.int; };
  options.protocol = lib.mkOption { type = lib.types.str; default = "http"; };
  options.healthPath = lib.mkOption { type = lib.types.str; default = "/health"; };
};

# Services can reference each other (direct ref — registry in scope)
options.services = genSchema.mkInstanceRegistry config.schema.service {
  extraModules = [({ ... }: {
    options.upstream = lib.mkOption {
      type = lib.types.nullOr (genSchema.ref config.services);
      default = null;
      description = "Upstream service this proxies to";
    };
  })];
};

config.services.api = { port = 8080; };
config.services.gateway = { port = 443; upstream = "api"; };

# Ref resolves to the full instance — accepts string keys or instance values:
config.services.gateway.upstream.port  # → 8080

Kubernetes resources — typed manifests with cross-references

config.schema.namespace = {
  options.labels = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = {}; };
};

config.schema.deployment = {
  options.replicas = lib.mkOption { type = lib.types.int; default = 1; };
  options.image = lib.mkOption { type = lib.types.str; };
  options.containerPort = lib.mkOption { type = lib.types.int; };
};

config.schema.service = {
  options.port = lib.mkOption { type = lib.types.int; };
  options.targetPort = lib.mkOption { type = lib.types.int; };
};

# Deployments reference their namespace (deferred ref on kind + binding)
config.schema.deployment.options.namespace = lib.mkOption {
  type = genSchema.ref "namespace";
};
options.deployments = genSchema.mkInstanceRegistry config.schema.deployment {
  refs.namespace = config.namespaces;
};

config.namespaces.production = { labels.env = "prod"; };
config.deployments.api = {
  namespace = "production";  # → resolves to the namespace instance
  image = "myapp:v1.2.3";
  replicas = 3;
  containerPort = 8080;
};

config.deployments.api.namespace.labels.env  # → "prod"

Homelab config — hosts with environment inheritance

# Shared base for all entity types
options.schema = genSchema.mkSchemaOption {
  baseModule.options.tags = lib.mkOption {
    type = lib.types.listOf lib.types.str;
    default = [];
  };
};

config.schema.host = {
  options.ip = lib.mkOption { type = lib.types.str; };
  options.os = lib.mkOption { type = lib.types.str; default = "nixos"; };
  methods.sshCmd = genSchema.schemaFn
    "SSH command for this host"
    lib.types.str
    ({ name, ip, ... }: "ssh root@${ip} # ${name}");
};

config.schema.network = {
  options.cidr = lib.mkOption { type = lib.types.str; };
  options.gateway = lib.mkOption { type = lib.types.str; };
};

# Hosts reference their network (deferred ref)
config.schema.host.options.network = lib.mkOption {
  type = genSchema.ref "network";
};
options.hosts = genSchema.mkInstanceRegistry config.schema.host {
  refs.network = config.networks;
};
options.networks = genSchema.mkInstanceRegistry config.schema.network {};

config.networks.lan = { cidr = "10.0.1.0/24"; gateway = "10.0.1.1"; };
config.hosts.nas = {
  ip = "10.0.1.10";
  network = "lan";
  tags = [ "storage" "backup" ];
};

config.hosts.nas.sshCmd        # → "ssh root@10.0.1.10 # nas"
config.hosts.nas.network.cidr  # → "10.0.1.0/24"
config.hosts.nas.tags          # → [ "storage" "backup" ] (from baseModule)

Core Concepts

Kinds

A kind is a named record type. Declare one by setting config.schema.<name>:

config.schema.host = {
  options.addr = lib.mkOption { type = lib.types.str; };
  options.system = lib.mkOption { type = lib.types.str; };
  options.role = lib.mkOption { type = lib.types.str; };
  config.system = lib.mkDefault "x86_64-linux";
};

Kinds are deferred modules — they define options and config but aren't evaluated until imported by an instance.

Kind names starting with _ are reserved for internal use (_kindNames, _topology, etc.). Declaring one evaluates — config.schema._hidden.kind reads "_hidden" — but every kind-derived read (_kindNames, _topology, _refEdges, _edges, _roots, _leaves) refuses it by name.

Extension

Any module can extend any kind. Extensions merge through deferred module merge:

# Module A declares base host options
config.schema.host.options.addr = lib.mkOption { type = str; };

# Module B (maybe from another flake input) adds monitoring fields
config.schema.host.options.metricsPort = lib.mkOption { type = int; default = 9100; };
config.schema.host.options.monitored = lib.mkOption { type = bool; default = true; };

Both contributions merge cleanly. Neither module needs to know about the other.

Base Module

A module injected into every kind automatically. Use it for options shared across all kinds without manual imports:

options.schema = genSchema.mkSchemaOption {
  baseModule = {
    options.description = lib.mkOption {
      type = lib.types.str;
      default = "";
      description = "Human-readable description.";
    };
  };
};

# Every kind gets `description` for free:
config.fleet.hosts.igloo.description  # → ""
config.fleet.users.tux.description    # → ""

baseModule is static — set at mkSchemaOption call time, not extensible by downstream modules. For extensible shared bases, use the kind mix-in pattern instead (a shared kind imported by others via imports).

Default Propagation

Kind modules can set default config values. These flow through to every instance via deferred module merge:

config.schema.host = {
  options.system = lib.mkOption { type = lib.types.str; };
  config.system = lib.mkDefault "x86_64-linux";
};

config.fleet.hosts.igloo = {};          # system → "x86_64-linux"
config.fleet.hosts.mac.system = "aarch64-darwin";  # override works

This is standard module-system priority behavior (gen-merge reproduces it byte-for-byte) — mkDefault sets a low-priority value that any explicit setting overrides.

Strict Validation

Kinds are strict by default — undeclared keys error immediately with a fix suggestion:

STRICT MODE: "addrr" is not declared on host.
Fix: schema.host.options.addrr = lib.mkOption { ... };

Opt out per-kind:

config.schema.host._module.freeformType = lib.types.attrsOf lib.types.anything;

Or globally:

options.schema = genSchema.mkSchemaOption { strict = false; };

Instances

Instances are concrete values of a kind. Create them with mkInstanceRegistry:

options.fleet.hosts = genSchema.mkInstanceRegistry config.schema.host {};

config.fleet.hosts.igloo = {
  addr = "10.0.1.1";
  role = "web";
  # system defaults to "x86_64-linux"
};

config.fleet.hosts.iceberg = {
  addr = "10.0.2.1";
  system = "aarch64-linux";
};

Each instance:

  • Gets a name option defaulting to the attrset key
  • Gets _module.args.<kind> = config for self-reference
  • Gets id_hash — a stable "<kind>:" + SHA-256 for safe comparison
  • Inherits the kind's strict/freeform setting

Nested Registries

Registries can nest inside instances via extraModules. This establishes parent-child relationships structurally:

# Capture the top-level schema before entering extraModules closures
let schema = config.schema;
in {
  options.fleet.hosts = genSchema.mkInstanceRegistry schema.host {
    extraModules = [({ config, ... }:
      let hostConfig = config;  # capture the host instance's config
      in {
        options.users = genSchema.mkInstanceRegistry schema.user {
          extraModules = [
            # Inject parent host into child user's module args
            ({ ... }: { config._module.args.host = hostConfig; })
          ];
        };
      }
    )];
  };
}

config.fleet.hosts.igloo = {
  addr = "10.0.1.1";
  users.tux.shell = "/bin/zsh";
  users.deploy.shell = "/bin/sh";
};

# Child instances access their parent:
config.fleet.hosts.igloo.users.tux._module.args.host.addr  # → "10.0.1.1"

Note the scoping: schema is captured at the top level (before the extraModules closure), and hostConfig captures the host instance's config (before the nested extraModules closure). Without these captures, config inside the closures would shadow the outer config.

Cross-entity bindings (_module.args.host = hostConfig) are the consumer's responsibility via extraModules. The schema library doesn't impose nesting semantics — different consumers wire cross-entity context differently.

Per-Kind Strict Override

Individual registries can override the schema-level strict setting:

# Schema is strict by default
options.schema = genSchema.mkSchemaOption { strict = true; };

# But this specific registry allows freeform
options.fleet.configs = genSchema.mkInstanceRegistry config.schema.config {
  strict = false;
};

Identity Hashing

Nix's == on module system values can diverge or infinitely recurse. id_hash gives you a cheap, stable string comparison:

# Safe entity comparison
builtins.filter (h: h.id_hash != host.id_hash) allHosts

# Set membership
lib.elem target.id_hash (map (h: h.id_hash) candidates)

The identity is the kind tag joined to a digest of the identity pairs:

id_hash = "<kind>:" + sha256(<the ⟨key, value⟩ pairs, as a JSON attrset>)

The pairs are all non-internal primitive options (str, int, bool, float) minus the declared opt-outs. Two hosts with the same values hash identically. A host and a user with the same name hash differently — the tag separates them, so id_hash says what it is and the kind is recoverable by splitting on the first colon. Because the tag rides outside the digest, : is refused in a kind name; values are unconstrained, since JSON quotes and escapes them.

Order does not enter. The pairs are rendered as an attrset and attrsets carry no order, so a caller supplying keys in any order mints the same node — there is no sort for a caller to get wrong.

id_hash is marked internal = true and readOnly = true — it won't appear in NixOS option documentation generators, but is always accessible via instance.id_hash.

Method returns are never identity keys. A method is declared as a readOnly option so its return reaches config, but it is marked identity = false at that point: a method is an arbitrary function of config, so admitting its return would let a method reading an opted-out field re-admit that field, and would make declaring a method move an instance's identity.

Floats are admissible only for |v| < 2^53, strictly. Identity follows Nix's == in both directions, and above that bound == is not an equivalence relation — two distinct ints both compare equal to one float — so there is nothing for an encoding to be faithful to. A float at or beyond the bound refuses by name at mint time. Ints are unrestricted.

Recomputing the hash for kind discovery — identityHashForKind kindValue instance. A consumer holding an instance value and a candidate kind value can recompute the identity and match the carried id_hash:

# which kind does `inst` belong to? (name-agnostic — by the id_hash marker, not the registry key)
lib.findFirst (kv: genSchema.identityHashForKind kv inst == inst.id_hash) null candidateKinds

It reflects the kind's own primitive options — honoring internal and identity = false — and routes through the same hashIdentity as mkIdentityModule, so the two cannot drift. A non-match reliably means "not this kind"; a wrong-kind false match would need a sha256 collision across different preimages.

There is deliberately no value-only form. Reflecting an instance value's own primitive attributes cannot honour the commitment above: a value carries no option metadata, so it sees neither internal nor the opt-out, and a method's return is just a primitive attribute in config that it would admit. One substrate has one minting authority, and two derivations that can disagree is one too many.

Three-layer precedence for key selection:

  1. Explicit _identity.keys — list the exact keys. Multiple modules can contribute via mkMerge.
  2. identity = false — exclude individual options from reflection.
  3. Auto-reflection — all non-internal primitives included (default).
# Layer 1: explicit keys — composable across modules
# Module A:
config.schema.host.config._identity.keys = [ "name" "addr" ];
# Module B (extends the kind):
config.schema.host.config._identity.keys = [ "vpnAlias" ];
# Result: [ "name" "addr" "vpnAlias" ] — list merge via mkMerge

# Layer 2: exclude an option from reflection
options.description = lib.mkOption { type = str; } // { identity = false; };

# Layer 3: automatic (default) — all non-internal str/int/bool/float options

Explicit keys are validated — referencing a nonexistent option or a non-primitive type throws at eval time:

_identity.keys: 'nonexistent' is not declared on kind 'host'
_identity.keys: 'tags' on kind 'host' is not a primitive type (str/int/bool/float)

Cross-Instance References

schema.ref declares a reference to another kind's instances. Two modes:

Deferred ref — declare on the kind, bind at registry time:

# Kind declares referential intent
config.schema.service.options.host = lib.mkOption {
  type = genSchema.ref "host";
};

# Registry binds the ref to a concrete registry
options.fleet.services = genSchema.mkInstanceRegistry config.schema.service {
  refs.host = config.fleet.hosts;
};

Direct ref — resolve immediately when the registry is in scope:

options.fleet.services = genSchema.mkInstanceRegistry config.schema.service {
  extraModules = [({ ... }: {
    options.upstream = lib.mkOption {
      type = lib.types.nullOr (genSchema.ref config.fleet.services);
      default = null;
    };
  })];
};

Both modes accept string keys or instance values:

config.fleet.services.nginx.host = "igloo";                    # string key → lookup
config.fleet.services.gateway.upstream = config.fleet.services.nginx;  # instance → passthrough

config.fleet.services.nginx.host.addr  # → "10.0.1.1"
config.fleet.services.gateway.upstream.port  # → 80

Invalid references throw at eval time:

ref field 'host' on kind 'service': reference 'nonexistent' not found in instance registry

Refs in Collections

ref works inside listOf and nullOr wrappers at any nesting depth:

config.schema.service.options.replicas = lib.mkOption {
  type = lib.types.listOf (genSchema.ref "host");
  default = [];
};

# String keys and instance values both work:
config.fleet.services.nginx.replicas = [ "igloo" "iceberg" ];
config.fleet.services.nginx.replicas = [ config.fleet.hosts.igloo "iceberg" ];

# Nullable and nested wrappers:
type = lib.types.nullOr (lib.types.listOf (genSchema.ref "host"));

Custom Ref Coercion

For domain-specific resolution, pass an extended binding with a coerce function:

options.fleet.services = genSchema.mkInstanceRegistry config.schema.service {
  refs.host = {
    instances = config.fleet.hosts;
    coerce = default: val:
      if val == "primary" then config.fleet.hosts.igloo
      else default;
  };
};

default is a lazy thunk of the standard coercion result — only forced if you select it. In listOf context, default is a single-element list and the hook can return multiple instances (1->many expansion).

Deferred Coerce (Self-Referential Registries)

When a registry's ref field points back to itself (e.g., a trait's needs referencing other traits in the same registry), standard coerce hooks cause infinite recursion — the coerce chain accesses the registry, which triggers apply, which runs the coerce chain again.

Set deferred = true to defer coercion to the applyPipeline (after all instances are evaluated). The coerce hook receives the raw materialized instances as its first argument, breaking the cycle:

options.traits = genSchema.mkInstanceRegistry config.schema.trait {
  refs.needs = {
    instances = config.traits;
    deferred = true;
    # 3-arg signature: registry is the raw instances (not config.traits)
    coerce = registry: default: val:
      if isSelector val then resolveAgainst registry val
      else default;
  };
};

Signature difference: Non-deferred hooks take 2 args (default: val:). Deferred hooks take 3 args (registry: default: val:), where registry is the pre-apply instance attrset. gen-schema pre-applies the registry, so mkCoerceChain sees a standard 2-arg function internally.

Deferred coerce runs before validators in applyPipeline, so validators see resolved instances and can check properties like .name on referenced entries.

Deduplicated Sets

setOf deduplicates by id_hash, preserving first-seen order:

config.schema.group.options.members = lib.mkOption {
  type = genSchema.setOf (genSchema.ref "host");
  default = [];
};

config.fleet.groups.web.members = [ "igloo" "iceberg" "igloo" ];
# → [ igloo iceberg ] — duplicate removed by identity hash

Composes with custom coerce hooks — expansion produces duplicates, setOf removes them.

Field References (Values)

ref is a type: it declares that a field points at an instance. fieldRef is the matching value: an inert record naming which instance, and which field of it.

fr = genSchema.fieldRef config.fleet.hosts.igloo [ "net" "addr" ];
# → { __genSchemaFieldRef = true; aspect = <igloo>; path = [ "net" "addr" ]; }

genSchema.isFieldRef fr                                    # → true
genSchema.fieldRefsIn { listen = { bind = fr; }; }
# → [ { at = [ "listen" "bind" ]; aspect = <igloo>; path = [ "net" "addr" ]; } ]

The target must carry id_hash — routing is by identity, and a display key is not one, so a name string is refused at application time. fieldRefsIn returns one record per ref it finds, with at giving the subpath (attribute keys and list indices) where the ref sat; [ ] means the scanned value was itself a ref.

The two levels are complementary and both are derived, never declared:

ref fieldRef
what it is an option type on a field a value inhabiting such a field
lives in the kind's schema a default or a contributed value
edges _refEdges, kind → kind (instance, field) → (instance, field)
refuses an unresolvable key, at merge time a non-identity target, at application time

fieldRefsIn refuses functions. A function found anywhere the scan descends throws, naming the position, rather than being treated as a leaf:

genSchema.fieldRefsIn { k = _: fr; }
# → error: gen-schema: fieldRefsIn: function at scanned position k — this scan's domain is data.
#   A function is refused rather than skipped, because a reference inside a closure is unreachable
#   to any structural scan: its edge could never be derived, so the dependency would go missing
#   silently. If this position is a computed value, express it where its reads stay visible —
#   `fieldRef <instance> <path>` for a cross-instance read, or the kind's `computed` hook for a
#   value derived from collections and defs; otherwise, make the position data, or keep the
#   function outside the scanned structure.

That is deliberate. Nix exposes no primitive that inspects a function body, so a ref inside a closure is invisible to any structural scan — and skipping it fails open: the edge is missing, a cycle it would have closed goes undetected, and the unresolved ref record leaks into output as data. Refusing eliminates the case instead of declaring it unanalysable, which keeps every dependence fact the scan reports a derived one. The values a ref scan is applied to are declared data, so on conforming input the refusal observes nothing.

If the refusal is in your way

The refusal is wider than the hazard, on purpose, and that is worth knowing before you work around it:

  • a function proved to contain no reference refuses anyway — the scan does not attempt to decide what a closure reads, because no structural scan can;
  • it reaches any depth of the value tree, including a function inside a foreign attrset stapled in from elsewhere;
  • a raw lambda as a computed value is foreclosed. Computed values are expected to route through constructs whose reads are graph-visible: fieldRef for a cross-instance read, computed for a value derived from collections and defs, derive on a registry for one derived per instance.

That covers the cases seen so far, and it is the first thing to reach for. If it genuinely does not, the escape is a declared one, not a quieter scan. The sanctioned shape follows the pattern ADR-0023 sets for the value-injection invariant — by-construction as the target, a declared opt-out as the interim, with the price recorded: refusal stays the default, and a field is annotated at schema level as not scanned, its reads declared here. The burden of arguing why those reads cannot be derived attaches to that annotation at that point, which is where it can actually be discharged — a concrete case exists to argue from. Re-opening the scan silently is explicitly not sanctioned: it trades a stated refusal for an unstated hole, which is the trade this refusal exists to reverse. No such annotation ships today; if you need one, that is a design conversation, not a patch to this file.

Parent-Child Topology

Kinds can declare their parent kind via the parent collection. This establishes a schema-level nesting relationship:

config.schema.host = {
  options.addr = lib.mkOption { type = lib.types.str; };
};

config.schema.user = {
  parent = "host";  # users nest inside hosts
  options.shell = lib.mkOption { type = lib.types.str; };
};

The parent collection is optional — kinds without it are root kinds. The schema derives both directions:

config.schema._topology.host   # → { parent = null; children = [ "user" ]; }
config.schema._topology.user   # → { parent = "host"; children = []; }

Declaring a parent that doesn't exist as a schema kind throws at eval time.

Schema Introspection

Every schema has flat _-prefixed options for programmatic access:

config.schema._kindNames    # → [ "host" "service" "user" ]
config.schema._roots        # → [ "host" ]  — kinds with no parent
config.schema._leaves       # → [ "user" ]  — kinds with no children

# Per-kind introspection
config.schema.host.options          # → full option declarations (filtered, no _module.*)
config.schema.host.refs             # → { }  (ref fields on this kind)
config.schema.host.strict           # → true
builtins.attrNames config.schema.host.options  # → [ "addr" "role" ... ]

# Unified edge view (§ Neron 2015 scope graph P + I edges)
config.schema._edges
# → [
#   { from = "user"; to = "host"; type = "parent"; field = null; }
#   { from = "service"; to = "host"; type = "ref"; field = "host"; }
# ]

# Ref edges only
config.schema._refEdges
# → [ { from = "service"; field = "host"; to = "host"; } ]

# Collection keys extracted from kind defs — built-ins plus this schema's own
config.schema._collectionKeys
# → [ "includes" "methods" "parent" "validators" ]

_edges combines parent edges (from topology) and ref edges (from schema.ref declarations) into a single typed list. Every edge has { from, to, type, field }field is null for parent edges and the option name for ref edges.

Scope Graph Bridge (Consumer-Side)

gen-schema provides generic introspection options (_topology, _edges, _kindNames, etc.) that consumers use to build whatever graph format their evaluator needs. The bridge logic lives in consumers (e.g., den's buildScopeGraphs), not in gen-schema.

Kind Mix-ins

A kind can import another kind's schema, inheriting all options:

config.schema.user = {
  options.userName = lib.mkOption { type = str; };
  options.shell = lib.mkOption { type = str; default = "/bin/bash"; };
};

config.schema.admin-user = {
  imports = [ config.schema.user ];  # inherits userName, shell
  options.sudoPrivileges = lib.mkOption { type = bool; default = true; };
  options.sshKeys = lib.mkOption { type = listOf str; default = []; };
};

Each gets its own registry. Identity hashes include the kind prefix — a user "root" and an admin "root" hash differently.

Multiple mix-ins compose cleanly:

config.schema.deploy-user = {
  imports = [
    config.schema.user
    config.schema.ssh-access
    config.schema.sudo-access
  ];
};

Declarative Methods

schemaFn declares functions on entity instances. Named arguments are automatically resolved from the instance's config:

config.schema.host.methods.describe = genSchema.schemaFn
  "Human-readable summary"
  lib.types.str
  ({ name, role, addr, ... }: "${name} (${role}) at ${addr}");

# On instances:
config.fleet.hosts.igloo.describe  # → "igloo (web) at 10.0.1.1"

Methods can close over values from the declaring module's scope:

# hasService captures config.fleet.services from the module;
# name comes from the host instance's config
config.schema.host.methods.hasService = genSchema.schemaFn
  "Check if a service targets this host"
  (lib.types.functionTo lib.types.bool)
  ({ name, ... }:
    serviceName:
    let services = config.fleet.services;
    in services ? ${serviceName}
       && services.${serviceName}.host.name == name);

config.fleet.hosts.igloo.hasService "nginx"     # → true
config.fleet.hosts.igloo.hasService "postgres"   # → false

Methods compose across modules — multiple modules can each add methods to the same kind:

# Module A
config.schema.host.methods.ping = genSchema.schemaFn
  "Ping command" lib.types.str
  ({ addr, ... }: "ping ${addr}");

# Module B (separate file, separate flake input — doesn't matter)
config.schema.host.methods.ssh = genSchema.schemaFn
  "SSH command" lib.types.str
  ({ name, ... }: "ssh ${name}");

# Both methods available on every host instance:
config.fleet.hosts.igloo.ping  # → "ping 10.0.1.1"
config.fleet.hosts.igloo.ssh   # → "ssh igloo"

If two modules declare the same method name, the later definition wins (attrset // semantics).

Methods with arguments that don't match any config key produce a clear error:

method 'bad' on host: references config keys 'nonexistent' which are not declared on this kind

Methods must be declared via inline attrsets, not path modules. This is a constraint shared with all collection fields.

Collection Fields

Declare custom collection fields on kinds — data extracted from definitions before module merge and exposed on the merged result:

options.schema = genSchema.mkSchemaOption {
  collections = {
    includes = { default = []; };           # list → merged via ++
    excludes = { default = []; };           # list → merged via ++
    metadata = { default = {}; };           # attrset → merged via //
    priority = { default = 0; merge = _acc: val: val; };  # explicit: last-wins
  };
};

config.schema.host = {
  includes = [ policy-a policy-b ];
  options.addr = lib.mkOption { type = str; };
};

# Read collection values directly:
config.schema.host.includes  # → [ policy-a policy-b ]

Merge strategy inference:

Default type Inferred merge Example
List ([]) acc ++ val includes, excludes
Attrset ({}) acc // val metadata, methods (built-in)
Other Explicit merge required priority = { default = 0; merge = _acc: val: val; }

Providing a non-list, non-attrset default without an explicit merge function throws at evaluation time.

Collection keys are stripped before the deferred module merge — they never leak into the module system. Collections must be declared via inline attrsets, not path modules (path defs get the collection's default value).

methods is a built-in collection with { default = {}; }. User-declared collections are additional. __functor is reserved and cannot be used as a collection key.

Multiple modules contributing to the same collection merge according to the collection's strategy:

# Module A
config.schema.host.includes = [ policy-a ];

# Module B
config.schema.host.includes = [ policy-b policy-c ];

# Result: [ policy-a policy-b policy-c ]

Computed Fields

Derived values computed from collection content and raw definitions:

options.schema = genSchema.mkSchemaOption {
  collections = {
    includes = { default = []; };
    excludes = { default = []; };
  };
  computed = collections: defs: {
    isEntity =
      let
        collectionKeys = lib.attrNames collections;
        hasStructuralContent = lib.any (d:
          let v = d.value;
              stripped = if builtins.isAttrs v
                then builtins.removeAttrs v collectionKeys else v;
          in !builtins.isAttrs stripped || stripped != {}
        ) defs;
      in
      collections.includes != []
      || collections.excludes != []
      || hasStructuralContent;
  };
};

config.schema.host.isEntity   # → true (has includes)
config.schema.conf.isEntity   # → false (empty — shared base only)

Introspection API

Every schema has flat _-prefixed options for programmatic access:

config.schema._kindNames                # → [ "host" "service" "user" ]
config.schema._collectionKeys           # → [ "includes" "methods" "parent" "validators" ]

# Per-kind introspection — available on each kind value
config.schema.host.options          # → full option declarations (filtered, no _module.*)
config.schema.host.refs             # → { field = { refKind = "targetKind"; type = ...; }; }
config.schema.host.strict           # → true
builtins.attrNames config.schema.host.options  # → [ "addr" "describe" "hasService" "metricsPort" ... ]

_collectionKeys is the set gen-schema extracts from kind defs before the deferred module merge — the built-in methods/validators/parent plus whatever collections this schema declared. It is derived from the same expression the extraction runs on, so a consumer reads it instead of maintaining its own copy of the list. Because it is the whole set, selection is positive and no prefix filter is needed:

prelude.genAttrs config.schema._collectionKeys (k: config.schema.host.${k})
# → { includes = [ "policy-a" ]; methods = { }; parent = null; validators = [ ]; }

Scope. That idiom reads collection values on the default entry-type path. Two caveats, both real: a computed field sharing a collection's name wins on the kind result ({ ... } // finalCollections // computedFields), so the idiom returns the computed value for that key, silently; and a caller-supplied mkType that does not spread collections onto its result makes the read fail with attribute '<k>' missing. Declaring a reserved collection key (__functor, kind) is refused when _collectionKeys is read, exactly as it is refused when a kind is merged.

Schema Validators

Declare cross-field validation constraints on kinds. Validators are a built-in collection — they travel with the kind and run automatically on every registry of that kind.

config.schema.host.validators = [
  (gen.mkValidator "has-addr"
    ({ addr, ... }: addr != "")
    "host must have a non-empty addr")
  (gen.mkValidator "valid-role"
    ({ role, ... }: lib.elem role [ "web" "db" "worker" ])
    "role must be one of: web, db, worker")
];

Validators compose across modules — multiple modules can contribute validators to the same kind via the collection ++ merge:

# Module A
config.schema.host.validators = [ (gen.mkValidator "a" ...) ];
# Module B
config.schema.host.validators = [ (gen.mkValidator "b" ...) ];
# Both fire on every host registry

When validation fails, errors accumulate (not short-circuit) and include the instance name, validator name, and message:

schema validation failed:
  host 'igloo': has-addr — host must have a non-empty addr
  host 'iceberg': valid-role — role must be one of: web, db, worker

For standalone validation without throwing, use validateInstances:

result = genSchema.validateInstances config.schema.host config.fleet.hosts;
# → { right = instances; } or { left = [ { name; validator; message; } ]; }

Derive Hooks

derive and deriveEither on mkInstanceRegistry compute values from the full evaluated registry and merge them back at high priority. The pipeline is: validate -> derive -> apply.

Plain derive — attrset in, attrset out:

options.fleet.users = genSchema.mkInstanceRegistry config.schema.user {
  derive = users:
    let uids = assignIds { min = 1000; max = 60000; } users;
    in lib.mapAttrs (name: _: { uid = uids.${name}; }) users;
  extraModules = [({ ... }: {
    options.uid = lib.mkOption { type = lib.types.int; readOnly = true; internal = true; };
  })];
};

config.fleet.users.tux.uid  # → 34213 (deterministic from id_hash)

Derive can read id_hash and all instance config — it runs after full module system evaluation. Derived fields must be internal = true (excluded from id_hash to avoid cycles) and readOnly = true (the derive hook is the only writer).

deriveEither — returns Either with configurable error handling:

options.fleet.services = genSchema.mkInstanceRegistry config.schema.service {
  deriveEither = {
    derive = services: someEitherPipeline services;
    onError = left: lib.warn "enrichment failed" {};  # optional, default throws
  };
};

derive and deriveEither are mutually exclusive. onError receives the left value — throw, warn, or return a fallback attrset. The default onError throws with a formatted message.

Validator errors flow through the same onError handler — a custom onError on deriveEither handles both validator failures and derive failures.

Documentation Generation

renderDocs produces markdown reference from schema metadata:

genSchema.renderDocs config.schema

Outputs a table per kind with option name, type, default, and description — including extensions from composition and methods.

Codec (Serialization)

mkCodec creates a standalone codec for serializing/deserializing kind instances. The codec is format-agnostic at its core, with a pluggable format layer and built-in JSON convenience.

codec = genSchema.mkCodec config.schema.host {
  # Optional: per-field overrides
  fields = {
    secret = { exclude = true; };
    cluster = { encode = v: v.name; decode = v: v; };
    meta = {
      fields = {
        region = {};
        internal = { exclude = true; };
      };
    };
  };
  # Optional: additional fields to exclude by name
  excludeFields = [ "tags" ];
};

The codec returns:

{
  encode        # instance → attrset (strips internals, encodes refs to names)
  decode        # attrset → attrset (drops unknown fields, passes through known)
  encodeAll     # registry → attrsOf attrset
  decodeAll     # attrsOf attrset → attrsOf attrset
  serialize     # format → instance → value
  deserialize   # format → value → attrset
  serializeAll  # format → registry → value
  deserializeAll # format → value → attrsOf attrset
  json          # { serialize, deserialize, serializeAll, deserializeAll }
}

Usage:

# JSON export
jsonStr = codec.json.serialize config.hosts.igloo;
# → "{\"addr\":\"10.0.1.1\",\"role\":\"web\",\"cluster\":\"prod\"}"

# JSON import (produces registry-compatible attrset)
imported = codec.json.deserialize (builtins.readFile ./host.json);

# Custom format
toml = { encode = tomlLib.encode; decode = tomlLib.decode; };
codec.serialize toml config.hosts.igloo;

Field resolution:

  • Internals (name, id_hash, methods, collections) are always excluded
  • Ref fields auto-encode to v.name (scalar), map (v: v.name) (listOf/setOf), with null-guard for nullOr
  • types parameter registers codecs by NixOS type name — auto-wrapped through nullOr/listOf/attrsOf/setOf
  • either/oneOf fields dispatch to the matching branch's codec via .check (left-biased)
  • Custom encode/decode in fields overrides auto-detection
  • fields.x = { fields = { ... }; } recurses into submodule structure
  • fields.x = { exclude = true; } removes a field

Round-trip: decode produces plain attrsets — ref fields return as strings. Resolution occurs when the decoded attrset enters an mkInstanceRegistry and passes through the existing ref coerce pipeline.

API Reference

mkSchemaOption

mkSchemaOption {
  strict ? true,        # strict-by-default validation on instances
  baseModule ? null,     # module imported into every kind
  collections ? {},      # { name = { default; merge? }; } — user-defined collection fields
  computed ? null,       # (collections -> defs -> attrset) — derived fields on merged result
  keySemantics ? {},     # { <key> = { category = <opaque string>; option? }; } — per-key category metadata (recorded, not interpreted)
}

Returns lib.mkOption — use as options.schema = mkSchemaOption { ... }.

mkSchemaEntryType is also exported for advanced use — it returns the type used for schema kind values (schemaKindEntry), without wrapping in mkOption or adding introspection options. Most consumers should use mkSchemaOption. The entry type delegates the module-collecting half of its merge to deferredModule but is not one: it is built through mkOptionType and so answers the option-type protocol for itself, which is what makes a redeclaration of the same schema option rebuild a schemaKindEntry with its collection extraction intact rather than a plain deferredModule without it.

mkSchemaEntryType mkType parameter

mkType replaces the standard deferredModule merge with a custom entry type constructor. When null (the default), kinds produce the standard deferred module with __functor wrapping, mixin pipeline, and refinement extraction. When provided, collection extraction still runs first, but mkType controls the merged result — the mixin pipeline, __functor wrapping, and refinement extraction are all skipped.

mkSchemaEntryType {
  mkType ? null,  # optional: { kindModule, collections, defs, kind } -> attrset
}

mkType receives four arguments in an attrset:

Argument Description
kindModule The resolved baseModule (after applying kind-name function, if any), or null
collections Extracted collection values (methods, validators, parent, plus user-defined)
defs Stripped definitions (collection keys removed) for wiring into the custom type
kind The kind name (last element of the option path)

The return value is merged with computedFields (computed wins for same-named keys), so topology and introspection fields remain authoritative.

keySemantics — opaque per-key category surface

mkSchemaOption {
  keySemantics = {
    nixos    = { category = "class"; };
    firewall = { category = "channel"; };
    neededBy = { category = "facet"; option = lib.mkOption { type = ...; }; };
  };
}

keySemantics is a per-key declaration surface recorded verbatim on the emitted schema entry (introspectable via config.schema.<kind>.keySemantics, alongside options/refinements/collections). category is an opaque string — gen-schema stores and threads it but assigns it no meaning; a downstream library interprets it (gen-aspects reads it to dispatch aspect key-options over the bounded set class/channel/facet). Where a <key> supplies an option recipe, gen-schema builds it into a real option on the entry; keys with no option are left for the consumer to synthesize from the recorded category.

This is what makes gen-aspects' dependency on gen-schema load-bearing: aspect key-options are declared through this per-key surface, not a private downstream field. No class/channel/facet literal appears anywhere in gen-schema — the category vocabulary and its meaning live entirely in gen-aspects. Default {} — recorded as empty when unset.

Use case: gen-aspects provides its recursive aspectType as a custom entry type, replacing gen-schema's deferred module with its own classification and dispatch system while reusing gen-schema's collection extraction and topology.

mkSchemaOption {
  mkType = { kindModule, collections, defs, kind }:
    myCustomType {
      inherit kind defs;
      inherit (collections) validators;
    };
}

mkInstanceType

mkInstanceType kindValue {
  extraModules ? [],     # additional modules (cross-entity bindings, den-specific options)
  strict ? kindValue.strict,
}

Returns lib.types.submodule — the type for a single instance of a kind.

mkInstanceRegistry

mkInstanceRegistry kindValue {
  extraModules ? [],
  refs ? {},             # bindings for deferred refs (see below)
  strict ? kindValue.strict,
  description ? "${kind} instances",
  derive ? null,         # { name → instance } → { name → attrset } — plain enrichment
  deriveEither ? null,   # { derive; onError? } — Either-based enrichment
}

Returns lib.mkOption with type = attrsOf (mkInstanceType ...) and an apply pipeline that runs validators then derive.

derive and deriveEither are mutually exclusive.

refs binds deferred ref fields to concrete registries. Three forms:

# Simple — registry directly:
refs.host = config.fleet.hosts;

# Extended — with custom coercion (2-arg):
refs.host = {
  instances = config.fleet.hosts;
  coerce = default: val: ...;  # default is lazy thunk of standard result
};

# Deferred — for self-referential registries (3-arg):
refs.needs = {
  instances = config.traits;
  deferred = true;
  coerce = registry: default: val: ...;  # registry = raw pre-apply instances
};

deferred = true runs coercion inside applyPipeline (after instances are materialized) instead of at option-apply time. The custom hook receives the raw instances as registry — use this instead of capturing the config value in the closure. Required when the ref field points back to the same registry being defined.

ref

ref target

target is a string -> deferred ref (kind name, bound via refs on mkInstanceRegistry). target is an attrset -> direct ref (resolved immediately). Both modes accept string keys or instance values.

The deferred ref's refKind sits inside the descriptor handed to mkOptionType, not stapled onto the finished type afterwards. mkOptionType stamps the protocol onto the record it is handed and mints a functor pointing back at that completed record, so a // { refKind = ...; } over a completed type leaves every protocol answer — typeMerge's rebuild included — describing a ref without its kind. Declaring the same option twice as ref "host" therefore merges to a ref(host) that getRefKind still reads through any nullOr / listOf / setOf wrapper and mkCoerceChain still builds a coercion for, rather than to a bare ref whose string keys reach the instance unresolved.

setOf

setOf elemType

A list type that deduplicates by id_hash, preserving first-seen order. Only meaningful with ref element types — setOf requires instance refs. Composes with custom coerce hooks: expansion produces duplicates, setOf removes them. Uses nestedTypes.elemType so getRefKind traverses through it like listOf.

setOf borrows listOf's value behaviour — the merge, the check — but is built through mkOptionType rather than as an override over a completed listOf, so it answers the option-type protocol as itself. A rebuild through substSubModules, and a typeMerge against a second declaration of the same option, both return a setOf with the isSetOf discriminator that mkCoerceChain and the codec dispatch on. A setOf and a plain listOf of the same element are different types and do not merge.

toSet

toSet instances

Converts a list of instances to a set with O(1) membership lookup via attrset backing. Deduplicates by id_hash (first-seen wins), so safe to call on any instance list. Returns:

{
  member = x: ...;  # O(1) membership test
  toList = [ ... ];  # deduplicated list, first-seen order
  length = n;        # number of unique instances
}

fieldRef / isFieldRef / fieldRefsIn

fieldRef instance [ fieldName]   # → { __genSchemaFieldRef = true; aspect; path; }
isFieldRef v                        # → bool
fieldRefsIn v                       # → [ { at; aspect; path; } ]

The value-level counterpart of ref — see Field References (Values). fieldRef throws unless the target carries id_hash and the path is a non-empty list of strings; the record carries the caller's own instance, so identity in ≡ identity out.

fieldRefsIn scans deeply for those records, returning one entry per hit with at giving the subpath where it sat ([ ] = the scanned value itself). It throws on a function in any scanned position: the scan's domain is data, and a ref inside a closure would be silently invisible. The marker key is exported as fieldRefMarker for consumers writing their own predicate.

schemaFn

schemaFn description type fn

Declares a method on a kind. fn receives an attrset of config values matching its named arguments. Declare via schema.<kind>.methods.<name> = schemaFn ....

mkValidator / runValidators / formatErrors / defaultOnError

genSchema.mkValidator name pred message              # → { name; pred; message; }
genSchema.runValidators kind validators instances    # → { right = instances; } | { left = [failure]; }
genSchema.formatErrors failures                      # → human-readable string
genSchema.defaultOnError left                        # throws with formatted errors

The base validator constructors, gen-schema-owned (relocated from gen-algebra on 2026-06-26). mkValidator's pred receives the instance config and returns bool; declare via schema.<kind>.validators = [ (genSchema.mkValidator ...) ]. runValidators evaluates them across a registry into an Either; validateInstances (below) is the kind-driven wrapper most consumers use.

mkIdentityModule / mkStrictModule

genSchema.mkIdentityModule kind   # NixOS module: injects id_hash + _identity.keys
genSchema.mkStrictModule kind     # NixOS module: rejects undeclared keys (closed-world)

The module-system constructors mkInstanceType injects into every instance (relocated from gen-algebra on 2026-06-26). mkIdentityModule derives a content-addressed id_hash by reflecting over a kind's primitive options (str/int/bool/float); _identity.keys pins the identifying fields explicitly. mkStrictModule sets a freeform type that throws on any key not declared as an option.

validateInstances

validateInstances kindValue instances

Runs the kind's validators against instances. Returns { right = instances; } on success or { left = [ { name; validator; message; } ]; } on failure. Does not throw — returns Either for consumer-controlled handling.

mkFieldValidator

mkFieldValidator {
  name = "validator-name";
  fields = [ "field1" "field2" ];  # optional — auto-skip kinds missing these fields
  check = inst: ...;               # predicate over instance config
  message = "error description";
}

Row-polymorphic validators with automatic field filtering. Validators with fields are automatically skipped for kinds that don't have all required fields. Validators without fields run unconditionally (backwards compatible).

filterValidators

filterValidators validators kindOptionNames

Filters a list of validators to only those whose fields (if declared) are all present in kindOptionNames. Used internally by mkInstanceRegistry to skip inapplicable field validators. Exported for consumers building custom validation pipelines.

renderDocs

renderDocs schema

Returns a markdown string with a table per kind.

mkCodec

mkCodec kindValue {
  fields ? {},           # per-field overrides: { name = { encode?; decode?; exclude?; fields?; }; }
  types ? {},            # codecs by NixOS type name — auto-wrapped through nullOr/listOf/attrsOf/setOf
  excludeFields ? [],    # additional field names to exclude from serialization
}

Returns a codec record with encode/decode (attrset ↔ attrset), format-parameterized serialize/deserialize, and curried json.* convenience. See Codec (Serialization) for full usage.

The refinement, blame, and mixin constructors below are exported flat off the library value — genSchema.refined, genSchema.refinements, genSchema.blame, genSchema.mkMixin, etc. There is no genSchema.types namespace; refined is a bare function (it lives at refinedLib.types.refined internally but is re-exported flat).

refined

Refinement contracts co-located with type declarations (§ Findler 2002, § Rondon 2008). Predicates validate during applyPipeline (strict by default).

# Single refinement
port = mkOption {
  type = genSchema.refined lib.types.int {
    check = self: self > 0 && self < 65536;
    message = "must be valid TCP port";
  };
};

# Composed refinements (all must pass)
port = mkOption {
  type = genSchema.refined lib.types.int [
    { check = self: self > 0; message = "must be positive"; }
    { check = self: self < 65536; message = "must be < 65536"; }
  ];
};

# Reusable
port = mkOption { type = genSchema.refined lib.types.int genSchema.refinements.tcpPort; };

Set lazy = true on a refinement to defer validation to access time via builtins.addErrorContext (§ Chitil 2012):

{ check = self: self > 0; message = "must be positive"; lazy = true; }

A refined type keeps its base's name — a refined int still says int in its error messages — but carries a distinct functor name, refined<int>, which is the identity two declarations of the same option are compared on. Declaring an option twice as the same refined type merges to that type with its refinements intact; declaring it once refined and once as the bare base is a reported type conflict rather than a silent drop to the unrefined type.

refinements

Built-in reusable refinements: tcpPort, nonEmpty, positive. Use with genSchema.refined to avoid repeating common predicates.

blame

Field-level error attribution for structured contract violations (§ Findler 2002).

genSchema.blame "fieldName" "error message"
# → { __blame = true; field = "fieldName"; message = "error message"; }

mkMixin

First-class reusable schema fragments with structural compatibility (§ Bracha 1990). define receives a record-algebra record and returns a plain attrset.

monitorable = genSchema.mkMixin {
  requires = [ "port" "hostname" ];
  provides = [ "metrics_port" ];
  # kinds = [ "service" ];  # optional kind constraint
  define = parent: {
    metrics_port = (record.select parent "port") + 1000;
  };
};

composeMixins

Compose multiple mixins into one. Requires propagation: earlier mixins' provides satisfy later mixins' requires.

enhanced = genSchema.composeMixins [ monitorable loggable healthcheck ];

# Mixed direction: beta mixin is overridden by what came before
combined = genSchema.composeMixins [
  monitorable
  (genSchema.beta tlsBase)  # Beta: existing fields win over tlsBase's
  loggable
];

beta

Annotates a mixin for Beta direction (§ Bracha 1990) — parent controls, meaning existing fields take precedence over the mixin's contributions.

applyMixin

applyMixin mixin kindRecord kindName

Applies a single mixin to a record-algebra record. Validates structural compatibility (requires) and optional kind constraint (kinds). Respects Smalltalk/Beta direction.

emitModule

Bridges record-algebra records to NixOS modules (§ Cardelli 1997). Strips refinement metadata from types. Extracts collections with full shadow stacks.

emitted = genSchema.emitModule [ "validators" "methods" ] recordAlgebraRecord;
# → { module = <NixOS module>; collections = { ... }; refinements = { ... }; }

mkSchemaEntryType mixins parameter

Mixins are auto-applied when baseModule is an inline attrset:

mkSchemaEntryType {
  mixins = [ monitorable loggable ];
  baseModule = {
    port = mkOption { type = types.int; };
    hostname = mkOption { type = types.str; };
  };
}

_internal

genSchema._internal.mkMethodsModule   # methods option/config wiring

Not part of the public API contract. Available for testing and advanced use.

Identity, strict, and validation primitives are gen-schema-owned — they relocated here from gen-algebra on 2026-06-26 (which is now fully pure). Import them from gen-schema.lib directly; gen-schema depends only on gen-algebra's pure record algebra.

Architecture

Schema kinds (deferred modules, parent collection, ref types)
  ↓ imported by
Instance types (submodules with strict + identity injected)
  ↓ collected into
Instance registries (attrsOf instance type, ref binding via apply)
  ↓ referenced by             ↓ introspected by
Cross-instance refs        _topology, _edges, _roots, _leaves
  (schema.ref)

Kinds are pure schema — options, config, defaults, methods, collections. No strict validation or identity hashing at the kind level.

Instances add infrastructuremkInstanceType injects mkStrictModule and mkIdentityModule. This separation means kind-level composition via imports works without duplicate module conflicts.

Collections are extracted before merge — collection keys on kind definitions are folded, merged, and exposed on the result. They never enter the deferred module merge.

File Layout

lib/
  default.nix       — public API surface, wiring (imports gen-algebra's pure record algebra)
  entry-type.nix     — mkSchemaEntryType, mkSchemaOption (collection extraction, introspection, topology)
  instance.nix       — mkInstanceType, mkInstanceRegistry (strict + identity injection, refs)
  id-hash.nix        — mkIdentityModule (id_hash via primitive-option reflection) + identityHashForKind.
                       The MINT is not here: `hashIdentity` lives in gen-identity and arrives injected
  strict.nix         — mkStrictModule (closed-world freeform rejection)
  ref.nix            — schema.ref (dual-mode cross-instance references, getRefKind)
  methods.nix        — schemaFn, mkMethodsModule (method option/config generation)
  validate.nix       — mkValidator, runValidators, formatErrors, defaultOnError (base) + validateInstances, mkFieldValidator, filterValidators (schema-specific)
  refined.nix        — refined (refinement contracts, § Findler 2002 / § Rondon 2008)
  blame.nix          — blame (field-level error attribution)
  mixin.nix          — mkMixin, composeMixins, beta, applyMixin (§ Bracha 1990)
  bridge.nix         — emitModule (record-algebra → NixOS module bridge, § Cardelli 1997)
  docs.nix           — renderDocs (markdown generation)
flakeModule.nix      — flake-parts integration (provides schema option + genSchema)

Identity hashing (mkIdentityModule), strict validation (mkStrictModule), and validators (mkValidator, runValidators, formatErrors, defaultOnError) are gen-schema-owned module-system constructors — they relocated here from gen-algebra on 2026-06-26 (which is now fully pure). They are exported on the public API and consumed internally by instance.nix. Cross-instance references use schema.ref (see ref.nix); the older mkRefType was retired in favor of ref's direct mode, which is a behavioral superset. gen-schema imports only gen-algebra's pure record algebra.

Demo

See examples/demo/ for a complete fleet management example using flake-parts + import-tree. The demo exercises all features: kinds, instances, strict validation, identity hashing, cross-instance references, schema composition, kind mix-ins, declarative methods, codec serialization, and documentation generation.

cd examples/demo
nix eval --override-input gen-schema ../.. .#fleet
nix eval --override-input gen-schema ../.. .#docs --raw

Testing

483 tests via nix-unit across 107 suites in ci/tests/ — both figures are command output rather than hand-maintained prose: nix-unit --flake ./ci#tests483/483 successful (exit 0), and git ls-tree --name-only HEAD ci/tests/ | grep -c '\.nix$'107, which is also the number of distinct suites the runner reports. Covering kinds, extension, strict validation, instances, identity hashing, cross-instance refs (deferred/direct/self-referential coerce, listOf/setOf/nullOr wrappers), collections and computed fields, methods, mixins, refinement contracts, blame, validators, derive hooks, codec round-trips, topology/edges introspection, and docs generation.

Run the itemized suite (from ci/):

cd ci
nix-unit --flake .#tests

Or build the aggregated check derivation:

cd ci
nix flake check

gen-schema runs on the pure-gen stack — gen-merge REPLACES lib.evalModules + lib.types, so gen-schema carries no nixpkgs.lib dependency.

Theoretical Foundations

gen-schema draws on seven papers. Four are directly implemented in the codebase; three inform the design without direct implementation.

Implements

Feature Paper Where
Refinement contracts with blame tracking § Findler & Felleisen -- Contracts for Higher-Order Functions (ICFP 2002) refined.nix: predicate contracts co-located with NixOS type declarations; blame.nix: field-level error attribution with { field, message } blame records; instance.nix: strict contract checking in applyPipeline
Lazy contracts with deferred validation § Chitil -- Practical Typed Lazy Contracts (ICFP 2012) instance.nix: lazy = true refinements wrap values with builtins.addErrorContext, deferring validation to access time -- matching Chitil's partial-identity semantics where unevaluated parts never trigger violations
Mixin composition § Bracha & Cook -- Mixin-Based Inheritance (OOPSLA 1990) mixin.nix: mkMixin/composeMixins implement Bracha's M1 * M2 = fun(i) M1(M2(i) + i) + M2(i) formula; beta reverses direction so parent controls; applyMixin validates structural requires
Refinement types § Rondon, Kawaguchi & Jhala -- Liquid Types (PLDI 2008) refined.nix: refined attaches predicate refinements to base NixOS types via __schema metadata, following Rondon's model of {v:B | e} base refinements co-located with type declarations

Informed by

Concept Paper Influence
Record algebra § Leijen -- Extensible Records with Scoped Labels (TFP 2005) gen-schema consumes gen-algebra's record.compose, record.select, record.mixin etc. The record algebra itself lives in gen-algebra; gen-schema uses it for mixin application and module bridging
Module linking § Cardelli -- Program Fragments, Linking, and Modularization (POPL 1997) bridge.nix: emitModule translates record-algebra records into NixOS modules (one-directional). Cardelli's linkset model -- separately compiled fragments linked via type-compatible substitution -- informs the design, though gen-schema doesn't implement the full linking calculus
Scope graph edge model § Neron, Tolmach, Visser & Wachsmuth -- A Theory of Name Resolution (ESOP 2015) entry-type.nix: _edges introspection uses Neron's P (parent) and I (import/ref) edge vocabulary to expose schema topology. gen-schema doesn't implement scope graphs or the resolution calculus -- that lives in gen-scope

About

No description, website, or topics provided.

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages