A how-to for giving shared aspects per-host knobs. If you build configurations with Den (or any dendritic / aspect-oriented Nix setup), this guide shows how to let an aspect expose a few typed values — a disk device id, a kernel optimization target, a BGP AS number — that each host fills in, without forking the aspect.
It is written to be replicated in your own configuration, not just read as a description of one repo. It has two parts:
-
Part 1 — the module-system pattern. What ships today: aspects declare options, a generator in your host schema discovers them and assembles one strongly-typed
host.settingstree, hosts set values, aspects read them back. This part includes the generator recipe — the ~80 lines you implement once in your host schema. Concrete examples are drawn fromsini/nix-config, used here purely as a reference implementation. -
Part 2 — the first-class settings citizen (addendum). The library-backed version shipping with den-hoag, built on gen-aspects: explicit per-field merge strategies, a graph-based cascade, a policy layer, contract-checked injection, and per-field provenance. This is where the pattern is headed.
Aspects (the reusable units composed into a host — roles, hardware profiles, disk layouts, services) are shared across many machines. Most of an aspect is identical everywhere. But a few values are inherently per-host:
- a ZFS disk layout needs the disk device id — different on every box;
- a kernel aspect needs the CPU optimization target —
zen4here,serverthere; - a BGP aspect needs this node's local AS number.
The naive fixes are all bad: hard-code the value and the aspect stops being reusable; add a bespoke top-level option per aspect and the host schema grows without bound; read it from a global and you lose typing and locality.
The settings pattern gives each aspect a typed, namespaced slot that the host fills in. Aspects declare their own option schema; the host schema discovers those declarations automatically and assembles them into one strongly-typed settings tree.
┌─ 1. Aspect DECLARES settings ──────────────────────────────────────────┐
│ den.aspects.core.system.linux-kernel = { │
│ settings = { optimization = lib.mkOption { ... }; }; │
│ }; │
└────────────────────────────────────────────────────────────────────────┘
│ (auto-discovered)
▼
┌─ 2. Host schema GENERATES the typed namespace ─────────────────────────┐
│ your host schema walks den.aspects, mirrors the tree, and produces │
│ a typed option at: │
│ host.settings.core.system.linux-kernel.optimization │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ 3. Host SETS values ──────────────────────────────────────────────────┐
│ den.hosts.x86_64-linux.cortex.settings = { │
│ core.system.linux-kernel.optimization = "zen4"; │
│ }; │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ 4. Aspect CONSUMES values ────────────────────────────────────────────┐
│ nixos = { host, pkgs, ... }: │
│ let cfg = host.settings.core.system.linux-kernel; in { ... }; │
└────────────────────────────────────────────────────────────────────────┘
The key property: steps 1 and 4 live in the aspect, step 3 lives in the host, and step 2 is automatic. Adding a setting to an aspect never requires touching the host schema. You implement the generator (step 2) exactly once.
The smallest aspect that exercises the whole pattern is a CachyOS kernel selector. Here it is in full — declaration and consumption both live in one file:
{ lib, ... }:
{
den.aspects.core.system.linux-kernel = {
# (1) DECLARE: two typed knobs, both with defaults.
settings = {
channel = lib.mkOption {
type = lib.types.enum [ "lts" "latest" ];
default = "latest";
description = "CachyOS kernel release channel";
};
optimization = lib.mkOption {
type = lib.types.enum [ "server" "zen4" "x86_64-v4" ];
default = "server";
description = "CachyOS kernel optimization target";
};
};
# (4) CONSUME: the delivery module receives the resolved `host` entity and
# reads its own settings back out of host.settings.<this aspect's path>.
nixos =
{ host, pkgs, ... }:
let
cfg = host.settings.core.system.linux-kernel;
kernelName =
if cfg.optimization == "server" then
"linuxPackages-cachyos-server-lto"
else
"linuxPackages-cachyos-${cfg.channel}-lto-${cfg.optimization}";
in
{
boot.kernelPackages = pkgs.cachyosKernels.${kernelName};
};
};
}And a host fills in the one knob it cares about, letting channel fall back to
its default:
den.hosts.x86_64-linux.cortex.settings = {
core.system.linux-kernel.optimization = "zen4";
};That is the entire contract. The host never imports the aspect's option type,
never names a special argument, never wires anything up. It writes
settings.core.system.linux-kernel.optimization and the value lands inside the
aspect under the same path.
Add a settings attribute to your aspect, as a sibling of includes, nixos,
and homeManager. The body is a set of plain lib.mkOption declarations:
den.aspects.disk.zfs-disk-single = {
includes = [ den.aspects.disk.zfs-disk-single.root ];
settings = {
device_id = lib.mkOption {
type = lib.types.str;
description = "Disk device path for ZFS pool (e.g., /dev/disk/by-id/nvme-...)";
};
};
nixos = { config, host, ... }: {
# ... uses host.settings.disk.zfs-disk-single.device_id
};
};Two conventions:
settingsholds option declarations, not config. PutmkOptions here, not assignments. (If you genuinely need to ship default config into the settings namespace, use the module-shaped form below.)- Omit
defaultto make a setting required.device_idabove has no default, so any host that includeszfs-disk-singlemust set it or evaluation fails with a missing-required-option error. This is the type system enforcing "you must tell me which disk."
| Form | Behavior |
|---|---|
mkOption { type = ...; } (no default) |
Required. Host must set it; missing → eval error. |
mkOption { type = ...; default = x; } |
Optional. Falls back to x when the host says nothing. |
Prefer defaults for anything with a sane fleet-wide value; reserve required options for genuinely machine-specific facts (disk ids, AS numbers).
A settings block is normally a bare attrset of options. The generator also
accepts a module-shaped declaration with explicit imports / config /
options keys, so an aspect can both declare options and seed default config
into the settings namespace:
settings = {
options = {
replicas = lib.mkOption { type = lib.types.int; default = 1; };
};
config = {
# computed default beyond what `default =` can express
replicas = lib.mkDefault 3;
};
imports = [ ./extra-settings-module.nix ];
};The generator reshapes a bare attrset into this same shape automatically, so you
only reach for the explicit form when you need config or imports. Because
the settings tree is evaluated by the NixOS module system, normal priorities
apply — lib.mkDefault / lib.mkForce all work, and a value the host sets
plainly wins over an aspect's mkDefault.
This is the half that lets others replicate the pattern: the bit of your host
schema that turns "aspects that happen to declare settings" into a single
strongly-typed host.settings option tree. You write it once; from then on
every aspect's settings appear automatically.
If you are new to Den, this is the one piece of framework background you need before the generator makes sense.
Den builds each entity kind — host, environment, user, group — from a
schema you declare under den.schema.<kind>. The key that matters here is
den.schema.<kind>.imports: a list of plain NixOS-style modules whose
options become that entity's options. Here is a complete, minimal entity
schema — the group entity, lightly trimmed — so you can see the bare shape:
{ lib, ... }:
let
inherit (lib) mkOption types;
in
{
den.schema.group.imports = [
(_: {
options = {
gid = mkOption {
type = types.nullOr types.int;
default = null;
description = "POSIX group ID";
};
# ... more options ...
};
})
];
}That is the entire skeleton: a file that is itself a flake-parts/den module, and
inside it a den.schema.<kind>.imports list. Every entity in Den — including
host — is built exactly this way; the host schema is just a bigger version of
the above.
The file's function arguments are your building blocks:
lib— nixpkgslib(mkOption,types,filterAttrs, …).inputs— your flake inputs (e.g. to instantiategen-algebrafor validators).den— the accumulated framework registry, and the reason the generator can exist at all.den.aspectsis the entire aspect tree;den.classesthe registered output classes (nixos,homeManager, …);den.quirksany extensions. The generator readsden.aspectsto discover settings andden.classes/den.quirksto know which keys are framework machinery rather than child aspects.
So the plan for the host is: compute a settingsType from den.aspects in the
file's let block, then attach it as one more option (settings) inside
den.schema.host.imports. The next two subsections show each half.
The generator below decides which keys to walk and which to skip via skipKey,
which consults den.lib.aspects.fx.keyClassification.structuralKeysSet (plus
den.classes / den.quirks). For this to work, Den must classify settings
itself as framework machinery, not as an ordinary aspect key. You declare that
once, in any module merged into the den config:
# e.g. modules/den/defaults.nix
den.reservedKeys = [ "settings" ];This adds settings to structuralKeysSet — exactly the set skipKey checks.
This step is not optional, and skipping it fails loudly. Without it,
skipKey "settings" is false, so the tree walk descends into your settings
block — into each mkOption, then into its type (a lib.types.* value whose
internal functor.type is self-referential) — and recurses forever:
nix-repl> den.hosts.x86_64-linux.<host>.settings
error: stack overflow; max-call-depth exceeded
at .../host.nix: … hasSettingsDeep …
The recursion path makes the cause obvious — it is walking option/type internals, which it must never touch:
<host>.settings.<aspect>.<key>.type.functor.type.functor.type.functor…
If you are porting this pattern to a non-Den framework, the rule generalizes:
whatever key you use for settings must be in the set skipKey consults before
you wire up the generator. Otherwise the generator mistakes your option
declarations for child aspects and walks straight into the type system.
The contract settingsType implements:
For every node in the aspect tree that has a
.settingsattribute, emit a typed submodule option at the same path underhost.settings. A node may be both an aspect-with-settings and a parent of settings-bearing children; merge both. Ignore keys that are framework machinery rather than child aspects.
Here it is in full, annotated. It is a single value computed in the host
schema's let block (so den, lib, and types are in scope). In
sini/nix-config this lives in modules/den/schema/host.nix.
settingsType =
let
# Keys that are NOT child aspects: structural keys (includes, nixos, …),
# plus your framework's registered class names and quirk/extension keys.
# Adapt these three sources to your own framework.
inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet;
classKeys = den.classes or { };
quirkKeys = den.quirks or { };
skipKey = k: structuralKeysSet ? ${k} || classKeys ? ${k} || quirkKeys ? ${k};
# A settings block may be a plain options attrset ({ foo = mkOption {...}; })
# OR module-shaped ({ imports; config; options; }). Normalize to the latter.
reshapeSettings =
raw:
let
# Bind to DISTINCT names on purpose — see the statix gotcha below.
imports' = raw.imports or [ ];
config' = raw.config or { };
in
{
imports = imports';
config = config';
options = removeAttrs raw [ "imports" "config" ];
};
# True if this node, or anything beneath it, declares settings.
hasSettingsDeep =
node:
builtins.isAttrs node
&& (
(node ? settings)
|| lib.any (k: !(skipKey k) && hasSettingsDeep (node.${k} or null)) (builtins.attrNames node)
);
# Build the submodule for one aspect-tree node, mirroring the tree.
# Merge the node's OWN settings options with recursion into its
# settings-bearing children.
nodeModule =
node:
let
ownSettings =
if node ? settings then
reshapeSettings node.settings
else
{ imports = [ ]; config = { }; options = { }; };
settingChildren = lib.filterAttrs (
k: v: !(skipKey k) && builtins.isAttrs v && hasSettingsDeep v
) node;
childOptions = lib.mapAttrs (
name: child:
mkOption {
type = types.submodule (nodeModule child);
default = { };
description = "Settings under ${name}";
}
) settingChildren;
# Distinct names again — keep statix from dropping the `or` default.
ownImports = ownSettings.imports or [ ];
ownConfig = ownSettings.config or { };
in
{
imports = ownImports;
config = ownConfig;
options = (ownSettings.options or { }) // childOptions;
};
in
types.submodule (nodeModule (den.aspects or { }));Now put both halves in one file. The generator lives in the let; the
settings option is attached inside den.schema.host.imports alongside the
host's other options. This is the whole host schema in skeleton form — the parts
that matter are highlighted, everything else (channel, networking, …) is a
normal mkOption you add as needed:
{ lib, inputs, den, self, ... }: # ← note `den` in the arguments
let
inherit (lib) mkOption types;
# ... other helpers: interfaceType, channel definitions, etc. ...
settingsType =
let
# skipKey / reshapeSettings / hasSettingsDeep / nodeModule
# (the generator from the previous subsection)
# ...
in
types.submodule (nodeModule (den.aspects or { })); # ← reads the aspect tree
in
{
den.schema.host.isEntity = true;
den.schema.host.imports = [
(
{ config, ... }:
{
options = {
channel = mkOption { /* ... */ };
environment = mkOption { /* ... */ };
# ... the rest of the host's options ...
# The generated, auto-discovered settings namespace:
settings =
mkOption {
type = settingsType;
default = { };
description = "Per-aspect typed settings";
}
# Exclude settings from entity identity hashing (see Gotchas).
// {
identity = false;
};
};
# config = { ... }; # computed defaults for other options, if any
}
)
];
}Three things to notice on a first read:
- The whole file is one module function with
denin its arguments — that is howsettingsTypecan seeden.aspects. If your framework hands you the registry under a different name, use that instead. settingsis just one option among many on the host. Nothing else in the schema needs to know it exists; aspects and hosts wire themselves up through it automatically.- The
// { identity = false; }marker isnix-config-specific (its entity identity hashing — see Gotchas). Omit it if your framework has no such concept.
That is the entire host-side investment: one option, backed by one ~80-line
let binding, written once. Every aspect that later declares settings shows
up under host.settings with no further schema edits.
How the three helpers earn their keep:
skipKeyis the only framework-specific part. It must returntruefor every key that is not a child aspect — structural keys (includes,nixos,homeManager, …), your class names, and any extension/quirk keys. Get this wrong and the generator will try to minthost.settings.<aspect>.nixos. Point it at whatever your framework uses to classify keys.hasSettingsDeepprunes the recursion so empty branches don't generate empty submodules — only paths that actually lead to asettingsblock become options.nodeModuleis the recursion proper. The crucial subtlety is that a node can carry both its ownsettingsand settings-bearing children. It mergesownSettings.optionswith the generatedchildOptions, so a parent aspect's own knobs and its children's knobs coexist under one path.
Because settingsType is a types.submodule, the resulting host.settings is
a normal NixOS option tree: typed, validated, and subject to module-system
priority (mkDefault/mkForce). You get type errors at the exact path for
free.
In a host definition, write a settings attribute. Address values by the
aspect's path. Nested and dotted attribute syntax are interchangeable in Nix:
den.hosts.x86_64-linux.cortex = {
channel = "nixpkgs-master";
environment = "dev";
# ...
settings = {
disk.zfs-disk-single.device_id =
"/dev/disk/by-id/nvme-Samsung_SSD_990_PRO_4TB_…";
core.system.linux-kernel.optimization = "zen4";
core.impermanence = {
wipeRootOnBoot = true;
wipeHomeOnBoot = false;
};
};
};Every value is typed, so a typo in the path or a wrong-typed value is a
build-time error pointing at the exact option, not a silently ignored attribute.
A host happy with every default writes no settings at all.
The aspect's delivery modules (nixos, homeManager) are functions that
receive the fully-resolved host entity as a module argument. Read your own
settings out of host.settings.<your.aspect.path>:
nixos =
{ config, host, ... }:
let
disk-device = host.settings.disk.zfs-disk-single.device_id;
in
{
disko.devices.disk.disk0.device = disk-device;
# ...
};Conventions that keep consumers readable:
- Bind a
cfgalias to your settings subtree (cfg = host.settings.core.system.linux-kernel;), then usecfg.optimization— exactly thecfg = config.services.fooidiom from upstream NixOS modules. - Read by the canonical path. Always read the same path you declared. An
aspect can read another aspect's settings (it is all one
host.settingstree), but do that sparingly — it couples the two aspects.
host is the resolved host entity, so the same argument also yields
host.system, host.environment, host.networking, etc. Settings are just one
branch of it.
Two layers of precedence compose.
Within a host, host.settings is a NixOS submodule, so module-system
priority applies. Weakest to strongest:
- the
default =on the aspect'smkOption; - any
configan aspect seeds via the module-shaped settings form (typicallylib.mkDefault); - the plain value the host writes in its
settingsblock; lib.mkForceanywhere overrides the lot.
Across the fleet, a settings cascade can layer
root → environment → host. In sini/nix-config this is a scope graph
(modules/den/scope-engine/settings.nix) that resolves, per node, local
shadowing imported shadowing parent, with environments exposing a loose
settings option for fleet- or environment-wide defaults:
root < environment < host (later wins)
In day-to-day aspect work you read the strongly-typed host.settings; the
cascade is the layer that lets an environment set a default for every host it
owns without each host repeating it. Part 2 generalizes this cascade into a
first-class, provenance-tracked construct.
-
stack overflow; max-call-depth exceededinhasSettingsDeep→ you forgotden.reservedKeys = [ "settings" ];. This is the single most common first-time failure. Ifsettingsisn't reserved,skipKeylets the tree walk descend into your option declarations'typevalues, which are self-referential, and the stack blows. See Required first: reservesettings. -
Declarations are options; config is config. A bare
settingsattrset must contain onlymkOptions. If you catch yourself writingfoo = "bar";(an assignment) directly insettings, you want eithermkOption { default = "bar"; }(to expose it as a knob) or the module-shaped form with aconfigblock (to seed it). -
The
or-default / statix W04 trap. The generator deliberately bindsimports' = raw.imports or [ ]andconfig' = raw.config or { }to distinct local names. Do not "simplify" these toinherit (raw) imports;— statix's W04 rule rewritesimports = raw.imports or [ ]intoinherit (raw) imports, which drops theordefault and throws the moment a plain-attrset settings block (the common case) has noimportskey. Keep the distinct bindings, and keep the explanatory comments next to them. (If you run statix via a formatter, this rewrite can sneak in on save — pin or disable W04 for that file.) -
Exclude settings from entity identity. If your framework hashes entities by their option values, mark
settings(likenetworking,facts,exporters) as identity-excluded so two hosts that differ only in settings stay distinct-by-name and settings don't perturb identity hashing. Innix-configthis is// { identity = false; }on the option. -
Path must match exactly.
host.settings.<path>consumed in an aspect must be the same path the aspect declaredsettingsunder. The generator keys off the aspect's position in the tree; there is no aliasing. -
A missing required setting fails loudly. Including an aspect without setting its required options is a hard error. That is the design — it makes "you forgot to say which disk" un-shippable rather than a runtime surprise.
- In the aspect, add an
mkOptionto itssettingsblock (create the block if absent). Give it adefaultunless it is genuinely per-host required. - In the aspect's
nixos/homeManagerfunction, takehostas an arg and readhost.settings.<aspect.path>.<key>(alias it tocfg). - On each host that needs a non-default value, set
settings.<aspect.path>.<key>in its host block. - Build the affected host to confirm the type resolves and required values are present.
Part 1 hand-rolls the mechanism out of the NixOS module system: settings are
mkOptions, the cascade is module priority plus a side scope graph, and an
aspect reads host.settings directly. It works and it is what ships today.
The pattern shipping with den-hoag promotes settings to a first-class,
library-backed construct via gen-aspects.
The reference is the gen-aspects demo
(examples/demo/modules/{composition,settings,injection}.nix). The differences
are deliberate and worth understanding before you build on the Part 1 version.
A settings field is a small schema leaf with a default and an optional merge
strategy — replace (default), append, or recursive:
# aspects/web.nix — services.nginx
settings = {
performance.workers = { default = 4; };
security.allowed-origins = { default = [ ]; merge = "append"; };
upstream.servers = { default = [ ]; merge = "append"; };
locations = { default = { }; merge = "recursive"; };
};merge makes the cascade's behavior explicit per field rather than implicit in
NixOS option types: append accumulates lists across layers, recursive
deep-merges attrsets per subkey, replace is last-wins. This is the single
biggest ergonomic gain over Part 1, where merge semantics are whatever the
option type happens to do.
The schema is registered as a typed collection on the aspect kind, so flatten
and the cascade can introspect it (this is the gen-schema replacement for Den's
old reservedKeys string-exclusion — declare settings via aspectModules):
aspectSchema = genAspects.mkAspectSchema {
classes = { nixos = { }; };
collections = { settings = { default = { }; }; tags = { default = [ ]; }; };
aspectModules = [
{ options.settings = lib.mkOption {
type = lib.types.lazyAttrsOf lib.types.raw; default = { };
}; }
];
};Instead of writing values inline on each host, overrides live in one
scopeSettings map keyed by scope node id (env:<name> / host:<name>):
config.scopeSettings = {
"env:prod" = { nginx.performance.workers = 16; app.logging.level = "warn"; };
"host:prod-web-1" = { nginx.performance.workers = 32;
nginx.upstream.servers = [ "app-1:3000" "app-2:3000" ]; };
};This separates "what an entity is" from "what settings it carries," and makes the environment layer a peer of the host layer rather than a side channel.
composition.nix runs a real pipeline rather than relying on module-merge
order:
- Extract every aspect's settings schema (
flatten+flattenSchema), producing flatdefaultsandstrategiesmaps namespaced by aspect leaf. - Build a scope graph (gen-scope): env nodes as roots, host nodes with parent-edges to their env.
- Collect layers with a gen-scope neron traverse in
D > I > Porder (most-specific first: host, then imports, then parent env), carrying a parallel list of contributing node ids so layers can be labeled. - Fold with
gen-algebra'srecord.foldLayersTraced, applying each field's strategy and recording, per field, which layer won.
aspect default: nginx.performance.workers = 4
env:prod: nginx.performance.workers = 16
host:prod-web-1: nginx.performance.workers = 32 ← wins (replace, last)
aspect default: nginx.upstream.servers = []
host:prod-web-1: nginx.upstream.servers = ["app-1:3000","app-2:3000"] (append)
A gen-derive fixpoint dispatches policy rules (e.g. "production hosts get
hardening", "db hosts get a backup schedule"). Rules emit typed configure
actions carrying { aspect; settings; }, which are folded in as the last
cascade layer — so policy beats both env and host:
layers = entityLayers ++ [ policyLayer ]; # policy appended LAST → wins
layerNames = entityNames ++ [ "policy" ];In Part 1 an aspect reaches into host.settings.<full.path>. Here the aspect's
class content is parametric over a settings argument, namespaced by the
aspect's leaf name:
nixos = { settings, host, lib, ... }: {
services.nginx.config = ''
worker_processes ${toString settings.nginx.performance.workers};
'';
};injection.nix closes the loop. For each (host, aspect) pair,
injectAspectSettings binds the cascade's composedSettings.<host>.<leaf>
(plus host) into the class content via genBind.wrap, with a contract
asserting settings is a set and a provenance stamp — producing a ready-to-
evalModules module:
injectAspectSettings = { host, aspectLeaf, classContent }:
(genBind.wrap {
module = classContent;
bindings = {
settings = { ${aspectLeaf} = composedSettings.${host}.${aspectLeaf} or { }; };
host = { name = host; } // (config.fleet.hosts.${host} or { });
};
contracts.settings = genBind.contract.isType "set";
provenance.settings = { source = "scope-settings"; scope = "host:${host}"; };
}).module;Resolved settings are injected before evalModules, so a parametric aspect
can read values that do not exist until the cascade has run — without the aspect
ever importing the cascade or knowing about scope ids.
foldLayersTraced records the winning layer per field (and per subkey on
recursive fields), turning "why is this value what it is?" into a value you
can assert on:
loggingLevelProdWeb1Winner # "policy" — policy beat env's "warn"
workersProdWeb1Winner # "host" — negative control; policy left it alone
dbBackupSubkeyProvenance # { schedule = "policy"; method = "host"; … }
| Concern | Part 1 (module system) | Part 2 (gen-aspects, den-hoag) |
|---|---|---|
| Declaration | mkOption (full NixOS types) |
schema leaf { default; merge?; } |
| Merge semantics | implicit in option type + module priority | explicit per field: replace / append / recursive |
| Override location | inline on the host entity | scopeSettings keyed by scope node id |
| Cascade | side scope graph; module priority in-host | gen-scope graph + neron traverse + traced fold |
| Policy layer | none (or ad-hoc) | gen-derive fixpoint configure actions, folded last |
| Aspect consumes | host.settings.<full.path> |
parametric settings.<leaf>, injected via gen-bind |
| Injection guarantees | module-system typing | contracts + provenance, bound before evalModules |
| Provenance | not tracked | per-field (and per-subkey) winning-layer trace |
- Reach for Part 1 when you want the smallest possible mechanism, full NixOS option typing/validation, and you are comfortable with module-merge semantics. It is a great default and the generator is ~80 lines.
- Reach for Part 2 when you need explicit per-field merge strategies, a layered cascade with a policy tier, settings injected into parametric modules with contracts, or auditable provenance ("which layer set this, and why?"). This is the direction den-hoag formalizes, so building on it aligns with where the framework is going.
The two are not exclusive: a config can keep mkOption-typed host.settings
for machine facts while adopting the gen-aspects cascade for fleet-wide feature
settings. The migration path is to move a setting's declaration from an
mkOption to a schema leaf and its consumption from host.settings.<path> to
an injected settings.<leaf> arg.