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.
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:
-
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-levelimportscannot readconfig.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 withmkIf (backend == "x"). A backend stored in a config option can never drive a top-level import. -
Synthesis wants to be a pure function. This is stage three of the "pure functional core / imperative shell" arc: D4 made file
layouta pure function; D6 made generatorcompilepure. The presentation concern's core ispresent :: 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.
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.nixbecomes the resolver: it looks up the selected record and wires its four contributions. No per-backendmkIfguards remain — all the guarding scattered across the backends collapses into the singlebackends.${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.
{
# 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.
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.
{ 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.
default.nix: declaresnixidy.presentation.argocd.*options + the back-compat aliases (mkRenamedOptionModulefor the oldappOfApps/defaultspaths) 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:mkApplicationbecomes a pure function ofctx; returnsTypedctx: { "${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>; }; }
Applications survive because the returned value flows through theapplicationssubmodule type (which is typed oncetypeImportsregisters the CRD). EverymkIf (backend == "argocd")is deleted.
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.
- All old paths (
nixidy.appOfApps.*, the argocd entries ofnixidy.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. backendwideningenum → stris backward-compatible (the two names still validate).
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.
- Pure
presentunit tests — callpresentwith a fixturectx, assert the returned objects directly (the D4layout-seam testing style). This is the structural win of making synthesis pure. - Dispatch validation test —
backend = "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.
functionTo rawin a submodule option. Apresentfunction stored in an option must survive module-system handling (no eager evaluation / no merge surprises). Mitigation:presentisfunctionTo raw; if the submodule merge proves awkward, fall back to typing the whole record asrawand validating structurally.- Assertion vs eager throw.
backends.${backend}must not throw before the assertion fires; theknown-guardedselfallback (§4) ensures the friendly assertion is what fails the build. bootstrapFileevaluation. Computed unconditionally at registration; it reads onlyargocd.name(has a default), so it is cheap and lazy even under a non-argocd backend.
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.