Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save sini/fc9e49e2c5b95d380ace43ed5bd1d181 to your computer and use it in GitHub Desktop.
Den pragmatic fleet features — learning from Clan-core, bridging strategy, five spikes, aspect library roadmap

Den Pragmatic Fleet Features — Learning from Clan, Bridging Strategy

Date: 2026-05-27 Companion to: 2026-05-27-clan-gen-integration-analysis.md (what gen offers Clan) This document: What den should borrow (functional gaps only), the bridging strategy, and the roadmap.

Framing

Den and Clan solve overlapping problems differently. Den has ~100s of users and strong formal foundations: scope graphs, algebraic composition, demand-driven evaluation, multi-class output, and the ability to materialize any output target (NixOS, darwin, homeManager, terraform, devshells, packages, treefmt, nix-unit, flake-parts perSystem) from a single aspect graph. Clan has a slightly larger user base and launched earlier, but forces NixOS module evaluation for every entity and can only target nixos/darwin.

Den's competitive advantages are real and hard to replicate:

  • Multi-target materialization from one aspect graph (not one pipeline per target)
  • Processing outside host-level evaluation — the scope graph resolves aspects, dedup, classification, and routing before any lib.evalModules call
  • O(subtree) not O(fleet) evaluation for single-host builds
  • Class-agnostic composition — the same aspect key dispatch works for terraform, devshells, packages, and NixOS configs

What den should borrow from Clan is limited to functional gaps — capabilities den doesn't have at all — not implementation patterns.


Part 1: Den's Multi-Target Architecture (Context for Reviewers)

Den's class system makes the aspect graph a universal materializer. A single aspect can emit into any registered output class:

# One aspect, multiple output targets
den.aspects.web-service = {
  nixos.services.nginx.enable = true;           # → nixosConfigurations
  darwin.services.nginx.enable = true;           # → darwinConfigurations
  homeManager.programs.firefox.enable = true;    # → home-manager config
  terranix = {                                   # → terraform config.tf.json
    resource.hcloud_server.web.server_type = "cx21";
  };
  devshell = { pkgs, ... }: {                    # → nix develop shell
    commands = [{ package = pkgs.curl; }];
  };
  packages.web-deploy = pkgs.writeShellScriptBin  # → nix build
    "deploy" "rsync ...";
  tests.web-smoke = { /* nix-unit test */ };     # → nix-unit
  treefmt.programs.nixfmt.enable = true;          # → treefmt
  files."README.md".text = "...";                 # → file generation
};

Each class is a den.classes.<name> registration + a route policy that determines where the collected modules end up:

# Register a class
den.classes.terranix = { };

# Policy: collect terranix modules from host subtree, route to flake output
den.policies.host-to-terranix = { host, ... }: [
  (den.lib.policy.instantiate {
    name = "${host.name}-tf";
    class = "terranix";
    instantiate = { modules, ... }: modules;
    intoAttr = [ "terranixModules" host.name ];
  })
];

# Feed into terranix's flake-module
perSystem = { pkgs, ... }: {
  terranix.terranixConfigurations = lib.mapAttrs (_: modules: {
    inherit modules;
  }) (config.flake.terranixModules or { });
};

This is den's greatest strength. The aspect pipeline processes all class keys through the same scope graph — dedup, classification, policy routing, pipe data flow — before any target-specific evaluation. Clan can't do this; their entire pipeline is coupled to lib.evalModules and the NixOS/darwin module system.

A Clan service that needs terraform resources requires a separate terranix integration outside the service framework. A den aspect just adds a terranix key.


Part 2: Functional Gaps (What to Borrow)

These are capabilities den lacks entirely. Not implementation patterns — just the features.

Gap 1: Secrets and Variables

Den has no secrets infrastructure. Every fleet needs one. Clan's vars system has the right feature set: generators with dependency ordering, pluggable backends (sops, age, in-repo), secret/public split, shared vs per-machine, file-level metadata.

What to build: A den.vars system that leverages the scope graph for visibility, gen-graph for dependency ordering, and gen-derive for backend selection.

Gap 2: Distributed Service Pattern

Den thinks host-first ("this host gets nginx"). Real fleets also need service-first thinking ("borgbackup needs servers and clients, here's how they find each other"). The role concept — multiple entity subsets cooperating within a service — has no den equivalent.

What to build: A den.services sugar layer that translates roles → nested aspects, membership → selectors, cross-role discovery → scope graph import edges.

Gap 3: Aspect Discoverability

Den aspects are opaque attrsets. You can't query "what aspects are available?", "what does this need?", or "what does this provide?" without reading code. Clan's manifests (name, description, categories, exports.out/inputs, constraints) solve this.

What to build: aspect.meta with gen-schema introspection for external tooling.

Gap 4: Fleet Inventory with Tags

Den discovers entities from den.hosts/den.homes declarations but has no fleet-level grouping. Clan's inventory provides tags, metadata, environments, and computed membership.

What to build: den.fleet with tags as scope graph node attributes, queryable via gen-select.


Part 3: The Bridge — C++/C Strategy

C++ succeeded partly because it compiled C code unchanged. The bridge wasn't perfect — C headers worked but didn't use classes or templates. But existing code was immediately useful, and migration happened incrementally.

The Mechanism

Den's namespace system is the bridge entry point. Namespaces are isolated aspect containers that import external definitions without collision:

# User's flake.nix — imports Clan services via bridge
imports = [
  (den.namespace "clan" [ inputs.clan-bridge ])
];

# Clan services available as den aspects
den.aspects.web1 = {
  includes = [
    clan.borgbackup           # ← bridged Clan service
    clan.zerotier             # ← bridged Clan service
  ];
  nixos.services.nginx.enable = true;   # ← native den aspect
  terranix.resource.hcloud_server = {}; # ← terraform (Clan can't do this)
};

The bridge translates _class = "clan.service" into den namespace aspects:

Clan construct Den translation
roles.<r> Nested aspect service/<r> with provides
roles.<r>.interface aspect.settings via gen-schema
roles.<r>.perInstance Parametric aspect (__args)
perMachine Static aspect (class content)
manifest aspect.meta
exports Pipe declarations
constraints gen-derive rules
roles.<r>.machines gen-select members

What Bridged Services Gain (That Clan Can't Provide)

  1. Multi-target output — a bridged borgbackup service could gain a terranix key for provisioning backup storage, a devshell key for operator tooling, and a tests key for nix-unit validation. In Clan these would be separate, uncoordinated integrations.

  2. Scope graph evaluation — building one backup client doesn't force evaluation of all servers. The cross-role references that cause Clan's O(R×I×M) blowup become lazy import edges.

  3. den-diagram visualization — bridged services participate in fleet topology rendering. Clan has no equivalent.

  4. Policy-driven composition — aspects compose based on scope context, not explicit machine lists. Adding a backup server means tagging a host, not editing a service definition.

  5. Dedup and classification — den's pipeline handles diamond includes, duplicate aspect resolution, and class key classification. Clan services that produce the same NixOS option from multiple paths get silent merge conflicts.

Bridge Tiers

Tier 1 (Day 1): Single-role services with no cross-role references. Direct translation: perInstance.nixosModule → den class key. ~20 of 32 Clan services.

Tier 2 (Week 1): Multi-role services with independent roles. Each role → nested aspect. hello-world, services with orthogonal roles.

Tier 3 (Week 2-3): Multi-role services with bidirectional references (borgbackup server↔client). Cross-role roles.*.machines → scope graph import edges + lazy peers.* queries.

Tier 4 (Month 1+): Services using vars/generators, exports, and constraints. Requires gen-vars and gen-derive constraint rules.

Migration Gradient

Services don't migrate all at once. A bridged Clan service, a partially-migrated service, and a fully native den service all coexist via namespace isolation:

den.aspects.web1 = {
  includes = [
    clan.matrix-synapse           # bridge: unchanged Clan service
    clan.borgbackup               # bridge: multi-role, scope graph queries
  ];
  nixos.services.nginx.enable = true;  # native: den aspect
  terranix.resource.hcloud_server = {} # native: can't do this in Clan
  devshell = { pkgs, ... }: {          # native: operator shell
    commands = [{ package = pkgs.opentofu; }];
  };
};

The native aspects add capabilities (terraform, devshells, tests) that the bridged services can gradually adopt.


Part 4: Five Spikes

Spike 1: gen-vars — Secrets and Variables

Gap: No secrets infrastructure.

Design:

den.vars.generators.wireguard-key = {
  scope = "host";          # per-host node in scope graph
  files.private.secret = true;
  files.public.secret = false;
  dependencies = [ "ssh-host-key" ];  # I-edge in scope graph
  script = ''
    wg genkey > $out/private
    wg pubkey < $out/private > $out/public
  '';
};

# Backend selection via gen-derive rules
den.vars.backends.sops = { /* ... */ };
den.vars.policies.production-uses-sops = { host, ... }:
  lib.optional (builtins.elem "production" (host.tags or []))
    (policy.varBackend "sops");

# In aspects — resolved via scope graph
den.aspects.wireguard.nixos = { vars, ... }: {
  networking.wireguard.interfaces.wg0.privateKeyFile =
    vars.wireguard-key.files.private.path;
};

Scope graph integration: generators become nodes, dependencies become I-edges, gen-graph topoSort sequences generation, fleet-level vars inherit via P-edges.

Why den's approach is better than Clan's: Vars participate in the scope graph — visibility follows the entity hierarchy automatically. A host-level var is visible to its users. A fleet-level var is visible to all hosts. Clan's vars use flat path conventions (vars/per-machine/...) with no structural visibility model.

Size: ~300-400 lines core + ~100 per backend.

Spike 2: Service Aspects — Distributed Service Model

Gap: No service-first thinking.

Design:

den.services.borgbackup = {
  meta = {
    description = "Encrypted backup with borg";
    categories = [ "Backup" ];
  };

  roles.server = {
    members = sel.attrs { tag = "backup-server"; };
    settings = { lib, ... }: {
      options.directory = lib.mkOption { default = "/var/lib/borgbackup"; };
    };
    aspect = { settings, peers, vars, ... }: {
      nixos = {
        services.borgbackup.repos = lib.genAttrs peers.client (name: {
          path = "${settings.directory}/${name}";
        });
      };
      # The same service can also emit terraform and devshell config
      terranix.resource.hcloud_volume.backup.size = 100;
      devshell = { pkgs, ... }: {
        commands = [{ name = "borg-status"; command = "..."; }];
      };
    };
  };

  roles.client = {
    members = sel.not (sel.attrs { tag = "no-backup"; });
    settings = { lib, ... }: {
      options.startAt = lib.mkOption { default = "*-*-* 01:00:00"; };
    };
    aspect = { settings, peers, ... }: {
      nixos.services.borgbackup.jobs = lib.genAttrs peers.server (name: {
        repo = "borg@${name}:."; startAt = settings.startAt;
      });
    };
  };
};

den.services is sugar — it compiles down to aspects + policies + pipes. The FX pipeline / HOAG evaluator only sees aspects. peers is a lazy gen-scope query, not an eager lib.mapAttrs over all machines.

Why den's approach is better than Clan's: Multi-target output (the same service definition produces NixOS config, terraform resources, and devshell tooling). Selector-based membership (not machine lists). O(subtree) evaluation (not O(R×I×M)).

Size: ~200-300 lines for service→aspect translation + ~100 for peer resolution.

Spike 3: Aspect Manifests + API Schema

Gap: Aspects are opaque.

Design:

den.aspects.nginx = {
  meta = {
    description = "Nginx reverse proxy with ACME";
    categories = [ "Web" "Network" ];
    requires = [ "acme" ];
    provides = [ "http-proxy" ];
  };
  settings = { lib, ... }: {
    options.port = lib.mkOption { type = lib.types.port; default = 443; };
  };
  nixos = { settings, ... }: { /* ... */ };
};

# Queryable
den.lib.aspects.catalog    # → { nginx = { description, categories, schema }; }
den.lib.aspects.byCategory "Network"  # → [ "nginx" "wireguard" ]
den.lib.aspects.validateDependencies  # → [ { aspect = "nginx"; missing = "acme"; } ]

gen-schema introspection generates the schema. gen-derive validates requires/provides. External tools consume catalog as JSON.

Size: ~150 lines.

Spike 4: Fleet Inventory with Tags

Gap: No fleet-level grouping.

Design:

den.fleet = {
  meta.name = "production";
  meta.domain = "example.com";

  tags = {
    production = [ "web1" "web2" "db1" ];
    gpu = [ "ml1" "ml2" ];
    backup-server = [ "storage1" ];
    # Computed tags
    nixos = hosts: builtins.filter (h: hosts.${h}.system == "x86_64-linux") (builtins.attrNames hosts);
  };

  environments.prod = {
    hosts = sel.attrs { tag = "production"; };
    meta.region = "us-east";
  };
};

Tags become scope graph node attributes. Environments add a scope level between fleet and host. gen-select queries replace flat set expansion.

Size: ~100-150 lines.

Spike 5: Settings Stratification

Gap: Implicit settings layering.

Design:

# Layer 1: Aspect declares interface
den.aspects.nginx.settings = { lib, ... }: {
  options.port = lib.mkOption { type = lib.types.port; default = 80; };
};

# Layer 2: Policy-driven defaults
den.policies.nginx-production = { host, ... }:
  lib.optional (builtins.elem "production" (host.tags or []))
    (policy.configure "nginx" { port = 443; });

# Layer 3: Host-level override
den.hosts.x86_64-linux.web1.aspects.nginx.settings.port = 8443;

Three explicit layers via gen-schema mixin composition (Bracha 1990). den-diagram can visualize which layer provided which value.

Size: ~100 lines.


Part 5: Priority and Sequencing

Priority Work item Depends on Size Value
1 Spike 5: Settings stratification ~100 lines Cleanest, proves the pattern
2 Spike 4: Fleet inventory + tags ~100-150 lines Enables everything else
3 Spike 3: Aspect manifests ~150 lines Catalog, validation, external tooling
4 Spike 1: gen-vars Spike 4 ~400-500 lines Unblocks secrets, Tier 4 bridge
5 Bridge Tier 1-2 Spikes 3-5 ~200 lines Immediate 20-service library
6 Spike 2: Service model Spikes 1, 4 ~300-400 lines Native distributed services
7 Bridge Tier 3-4 Spikes 1-2 ~300 lines Full 32-service library

Total: ~1,350-1,800 lines for all spikes + bridge.


Part 6: What We Do NOT Borrow

Clan feature Why not
Evaluation model (O(R×I×M) evalModules) Den's scope graph is strictly better — O(subtree)
JSON Schema generation (26K lines) gen-schema introspection replaces this
NixOS-coupled pipeline Den processes outside evalModules — this is the performance win
Tag resolution (flat set expansion) gen-select is strictly more expressive
String-based scopes Scope graphs with formal resolution are the whole point
Module merge for composition Algebraic graph composition (Mokhov) is provably order-independent
Two-target limitation (nixos/darwin) Den's class system handles any number of output targets

Non-Goals

  • Not forking Clan. The bridge consumes Clan services unchanged.
  • Not matching Clan's API. Bridged services use den's API, not Clan's. The translation is structural.
  • Not replacing Clan's CLI. Den's tooling story is separate.
  • Not all-or-nothing. Each spike ships independently. The bridge is useful at Tier 1 without waiting for Tier 4.
  • Not borrowing implementation patterns. Den borrows features (secrets, service roles, manifests, tags) — not how Clan implements them. The implementations use den's scope graph, gen libraries, and class system.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment