Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

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

Select an option

Save sini/d2a1b7876977e01cfb0712ee8d89dcfe to your computer and use it in GitHub Desktop.
nixidy: pure-present presentation-backend registry — design spec (proposed for PR #104)

D3 (revised): Pure-present presentation-backend registry

Status: design, approved for spec review Date: 2026-07-17 Supersedes the synthesis model of 2026-06-11-d3-presentation-backend-design.md (the enum + guarded-modules dispatch). The per-app-options threading, the ArgoCD option relocation + back-compat aliases, and the minimal Flux proof carry over unchanged; only the dispatch and synthesis mechanism is redesigned.

Branch: refactor/presentation-backend (PR arnarg/nixidy#104), now rebased onto the ready-to-merge #102 (build/ layout seam) at 8c3c6b0.

1. Motivation

PR #104 opened the presentation backend as a draft proposal. arnarg is receptive to a pluggable GitOps presentation backend "as long as the original ArgoCD experience doesn't suffer," and proposed the selector carry the backend itself:

nixidy.presentation.backend = with types;
  either (enum [ "argocd" "flux" ]) (oneOf [ package path (functionTo attrs) ]);

Two facts reshape that proposal:

  1. A backend-as-config-value cannot be imported. A backend contributes top-level config — synthetic apps carrying controller objects, the per-app options list, the CRD type registration. Those must come from a module in the top-level imports, and top-level imports cannot read config.nixidy.presentation.backend (imports are the fixpoint that config is computed from). Today's code sidesteps this by pre-importing both built-ins and guarding each with mkIf (backend == "x"). A backend stored in a config option can never drive a top-level import.

  2. Synthesis wants to be a pure function. This is stage three of the "pure functional core / imperative shell" arc: D4 made file layout a pure function; D6 made generator compile pure. The presentation concern's core is present :: apps → controller-objects. If synthesis is a pure function whose result is assigned (not a module that's imported), the import-timing wall disappears entirely — you call it and merge its output.

arnarg's functionTo attrs instinct is exactly this pure present function; it was only hung off a config option where it can't be imported. This design keeps the function and drops the un-importable "as a module value" part.

2. Overview

Replace enum + per-backend mkIf guards with a registry of pure backend records and one central dispatch:

  • nixidy.presentation.backends : attrsOf <backend-record> — built-ins register themselves; users add entries.
  • nixidy.presentation.backend : str — the dispatch key (default "argocd"), with an assertion that it names a known backend.
  • modules/presentation/default.nix becomes the resolver: it looks up the selected record and wires its four contributions. No per-backend mkIf guards remain — all the guarding scattered across the backends collapses into the single backends.${backend} lookup.

Registration is unconditional and free: a built-in backend module sets config.nixidy.presentation.backends.argocd = { … } with no guard, because present is a lazy function only called for the selected key.

3. The backend record (the contract)

{
  # Modules contributing per-application options (applications.<name>.<key>.*).
  # Threaded into the applications submodule TYPE via the existing perAppModules bridge.
  perAppOptions ? [ ];

  # Modules registering resource TYPES this backend emits (e.g. the ArgoCD
  # Application CRD). Threaded into applicationImports (gated on baseImports).
  typeImports ? [ ];

  # PURE synthesis. Given the read-model `ctx`, returns app-config that the
  # module system merges into `applications` (typed resources or raw objects).
  present ? (ctx: { });

  # Filename (within build.bootstrapPackage) of the rendered bootstrap manifest,
  # or null. Computed by the registering module (which has config access).
  bootstrapFile ? null;
}

Option type: attrsOf (submodule { options = { perAppOptions = listOf raw; typeImports = listOf raw; present = functionTo raw; bootstrapFile = nullOr str; }; }). raw (not applicationImports' precise oneOf) because a backend may contribute any module value.

3.1 The ctx read-model

present receives a curated projection of config — its true, minimal inputs, concern-grouped — not the whole config/cfg god-object. This makes the dependency surface explicit and lets a present function be unit-tested with a small fixture (the gen accessor-ctx instinct).

ctx = {
  target     = { repository; branch; rootPath; };  # where manifests are synced from
  env;                                             # environment name
  appendNameWithEnv;                               # naming policy (argocd suffixes by env)
  apps;                                            # config.applications — read per-app fields
  publicApps;                                      # the app names to present
  lib;
  helpers;                                         # applications/lib.nix (objectBaseName, …)
};

present reading ctx.apps while its result merges into applications is the same lazy self-reference the current present.nix already relies on: synthesis reads apps' option-set fields (namespace, output.path, backend opts) — never the resources/objects it itself writes. The app-of-apps app legitimately reads its own option fields (mkApplication config.applications.${argocd.name}) while writing that same app's resources.applications; a lazy field-read, not a true cycle. The implementation must keep mkApplication reading only ctx.apps.<name> option fields, not anything present assigns.

4. Dispatch (modules/presentation/default.nix)

{ lib, config, pkgs, ... }:
let
  cfg = config.nixidy;
  known = cfg.presentation.backends ? ${cfg.presentation.backend};
  # Safe fallback so evaluation is well-defined when the key is unknown; the
  # assertion below is what actually fails the build, with a friendly message.
  sel = if known then cfg.presentation.backends.${cfg.presentation.backend} else { };
  ctx = {
    target = { inherit (cfg.target) repository branch rootPath; };
    inherit (cfg) env appendNameWithEnv publicApps;
    apps = config.applications;
    inherit lib;
    helpers = import ../applications/lib.nix lib;
  };
in
{
  imports = [ ./argocd ./flux ];  # built-in backends: declare options + register records

  options.nixidy.presentation = with lib; {
    backend = mkOption { type = types.str; default = "argocd"; description = "…"; };
    backends = mkOption { type = with types; attrsOf (submodule {}); default = { }; description = "…"; };
    perAppModules = mkOption { type = with types; listOf raw; default = [ ]; internal = true;};
    bootstrapManifestFile = mkOption { type = with types; nullOr str; default = null; internal = true;};
  };

  config = {
    nixidy.assertions = [{
      assertion = known;
      message =
        "unknown presentation backend `${cfg.presentation.backend}`; "
        + "known backends: ${lib.concatStringsSep ", " (lib.attrNames cfg.presentation.backends)}.";
    }];

    nixidy.presentation.perAppModules = sel.perAppOptions or [ ];
    nixidy.applicationImports = lib.mkIf cfg.baseImports (sel.typeImports or [ ]);
    nixidy.presentation.bootstrapManifestFile = sel.bootstrapFile or null;
    applications = (sel.present or (_: { })) ctx;
  };
}

perAppModules and bootstrapManifestFile stay as internal bridge options — perAppModules is the proven config→submodule-type bridge that applications.nix reads; bootstrapManifestFile is the generic seam extra-files.nix consumes. They are now fed by the dispatch instead of set inside each backend's guarded block.

5. Backend ports

5.1 ArgoCD (modules/presentation/argocd/)

  • default.nix: declares nixidy.presentation.argocd.* options + the back-compat aliases (mkRenamedOptionModule for the old appOfApps/defaults paths) unconditionally (as today, so aliases always have a target), and registers the record unconditionally:
    config.nixidy.presentation.backends.argocd = {
      perAppOptions = [ ./options.nix ./aliases.nix ];
      typeImports   = [ ../../generated/argocd.nix ];
      present       = import ./present.nix;      # ctx: { … }
      bootstrapFile = "${helpers.objectBaseName { kind = "Application"; metadata.name = cfg.presentation.argocd.name; }}.yaml";
    };
  • present.nix: mkApplication becomes a pure function of ctx; returns
    ctx: {
      "${argocd.name}" = { namespace; argocd = { project; destination; syncPolicy.autoSync = mkLowerDefault; }; resources.applications = <per public app>; };
      __bootstrap      = { namespace; argocd.project; resources.applications.${argocd.name} = <app-of-apps Application>; };
    }
    Typed Applications survive because the returned value flows through the applications submodule type (which is typed once typeImports registers the CRD). Every mkIf (backend == "argocd") is deleted.

5.2 Flux (modules/presentation/flux/)

  • default.nix: registers the record; perAppOptions = [ ./options.nix ]; present = ctx: { __flux-system.objects = [ gitRepository ] ++ map mkKustomization … ++ [ rootKustomization ]; }; typeImports = [ ]; bootstrapFile = null. Stays the minimal raw-object proof.

6. Back-compat & migration

  • All old paths (nixidy.appOfApps.*, the argocd entries of nixidy.defaults.*) remain value-forwarded via the existing aliases → non-breaking; existing configs (incl. nix-config) evaluate unchanged.
  • Default-backend (argocd) rendered output is byte-identical to pre-change (the synthesis logic is relocated, not altered). This is the primary oracle.
  • Existing presentation tests (presentation-argocd-aliases, presentation-flux, bootstrap-manifest) carry over.
  • backend widening enum → str is backward-compatible (the two names still validate).

7. Scope / non-goals — the two-axis boundary

In scope: the pure-present synthesis seam, the backend registry, string-key dispatch + validation, user-backend extensibility.

Explicitly deferred — the delivery axis. What controller objects describe the apps (presentation) is orthogonal to how manifests reach the cluster (delivery/activation: commit-to-git vs build-and-push OCI artifact). arnarg noted the Flux community treats committed rendered manifests as an anti-pattern and prefers OCI artifacts. That is a delivery concern, not presentation, and belongs to a separate future seam over D4's apply/activation. Pure present keeps presentation delivery-agnostic by construction (it returns objects; it does not care how they ship), so the deferral couples nothing:

  • an OCIRepository-emitting Flux variant → a future registry entry (presentation axis);
  • an OCI build-and-push activation → a future delivery seam (D4 axis).

This is a concern-boundary, not a half-measure: the seam does not bake in git-delivery.

Other non-goals (unchanged from the original D3): single backend per environment; the minimal Flux backend is illustrative, not production (no HelmRelease/sync parity); applications is not renamed.

8. Testing

  • Pure present unit tests — call present with a fixture ctx, assert the returned objects directly (the D4 layout-seam testing style). This is the structural win of making synthesis pure.
  • Dispatch validation testbackend = "does-not-exist" fails with the friendly known-backends assertion.
  • Example user backend — a tiny in-tree test backend registered via backends.<name> and selected, proving third-party extensibility end-to-end (registration + present + perAppOptions).
  • All existing module + lib tests remain green; default-backend byte-identical output is verified against the pre-change environment package.

9. Risks & open questions

  • functionTo raw in a submodule option. A present function stored in an option must survive module-system handling (no eager evaluation / no merge surprises). Mitigation: present is functionTo raw; if the submodule merge proves awkward, fall back to typing the whole record as raw and validating structurally.
  • Assertion vs eager throw. backends.${backend} must not throw before the assertion fires; the known-guarded sel fallback (§4) ensures the friendly assertion is what fails the build.
  • bootstrapFile evaluation. Computed unconditionally at registration; it reads only argocd.name (has a default), so it is cheap and lazy even under a non-argocd backend.

10. Deliverable framing for the PR thread

The reply to arnarg leads with: (a) his functionTo attrs instinct is right — it becomes the pure present function; (b) it can't live on a config option (the import-timing reason), so it lives in a registry record and is called, not imported; (c) this finishes the pure-core arc D4/D6 started; (d) OCI is the separate delivery axis, deferred deliberately, enabled cleanly by the pure seam.

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