Ornaments and Description-Backed Data
Generated datatypes in nix-effects are description-backed. A datatype is
not only a bundle of constructors; it also carries a Desc tree and
metadata that generic tools can inspect. That is what makes the same
datatype usable by the checker, value walkers, schema derivation,
dependency extraction, and ornaments.
An ornament refines one generated datatype into another while preserving a forgetful map back to the base datatype. Use it when one layer needs more information than another layer, but the enriched value should still be usable wherever the base value was expected.
Description-backed datatypes
Start with an ordinary generated datatype:
let
fx = import ./nix/nix-effects {};
H = fx.types.hoas;
G = fx.types.generic;
Aspect = H.datatype "Aspect" [
(H.con "aspect" [
(H.field "name" H.string)
(H.field "target" H.string)
(H.field "requires" (H.listOf H.string))
])
];
in Aspect
Aspect exposes:
Aspect.D: the description used by the kernel.Aspect.T: the generated type.Aspect.aspect: the constructor._dtypeMeta: constructor and field metadata used by generic tools.
The generic layer is the usual interface for description-backed programming:
let
info = G.datatype.datatypeInfo Aspect.T;
value = G.value.review Aspect.T {
_con = "aspect";
name = "workspace-shell";
target = "module";
requires = [ "toolchain" ];
};
in {
constructors = map (c: c.name) info.constructors;
roundtrip = G.value.view Aspect.T value;
}
review turns a constructor record into a HOAS value. view turns the
HOAS value back into a named constructor record. Derivation helpers such
as G.derive.deriveDescriptor, G.derive.deriveSchema, and
G.derive.deriveDeps consume the same metadata.
Plain ornaments
A plain ornament adds fields while retaining enough structure to forget back to the base datatype:
let
ResolvedAspect = G.ornaments.ornament Aspect {
name = "ResolvedAspect";
constructors.aspect.fields = [
{ keep = "name"; }
{ keep = "target"; }
{ keep = "requires"; }
{ insert = "resolvedPath"; type = H.string; }
];
};
resolved = G.value.review ResolvedAspect.T {
_con = "aspect";
name = "workspace-shell";
target = "module";
requires = [ "toolchain" ];
resolvedPath = "modules/workspace-shell.nix";
};
in G.value.view Aspect.T (G.ornaments.forget ResolvedAspect resolved)
The result is:
{
_con = "aspect";
name = "workspace-shell";
target = "module";
requires = [ "toolchain" ];
}
The important property is one-way. Many resolved aspects may forget to the same aspect. The ornament says how to drop the inserted structure without changing the base shape.
Plain ornaments are useful when the enriched value already exists. They give you checked forgetful maps, composition, and pullback transport:
G.ornaments.forget ResolvedAspect resolved
G.ornaments.compose Outer Inner
G.ornaments.pullback ResolvedAspect baseFunction resolved
Functional ornaments
A functional ornament adds the forward direction: a section of the forgetful map.
section : base -> ornamented
forget (section x) = x
In the generic API, build a functional ornament from a base datatype, an ornament spec, and explicit synthesis functions for inserted fields:
let
ResolvedAspect = G.ornaments.functional {
base = Aspect;
spec = {
name = "ResolvedAspect";
constructors.aspect.fields = [
{ keep = "name"; }
{ keep = "target"; }
{ keep = "requires"; }
{ insert = "resolvedPath"; type = H.string; }
];
};
synth.constructors.aspect.fields.resolvedPath =
ctx: "modules/${ctx.baseRecord.name}.nix";
};
aspect = G.value.review Aspect.T {
_con = "aspect";
name = "workspace-shell";
target = "module";
requires = [ "toolchain" ];
};
resolved = G.ornaments.build ResolvedAspect aspect;
in {
enriched = G.value.view ResolvedAspect.meta.ornamented.T resolved;
forgotten = G.value.view Aspect.T (G.ornaments.forget ResolvedAspect resolved);
}
The enriched record contains the synthesized resolvedPath:
{
_con = "aspect";
name = "workspace-shell";
target = "module";
requires = [ "toolchain" ];
resolvedPath = "modules/workspace-shell.nix";
}
The forgotten record is the original aspect. This gives a practical workflow: construct the small base value first, then let the functional ornament add canonical metadata.
Composing enrichments
Functional ornaments compose when the outer ornament starts from the inner ornament's generated datatype:
let
MaterializedAspect = G.ornaments.functional {
base = ResolvedAspect.meta.ornamented;
spec = {
name = "MaterializedAspect";
constructors.aspect.fields = [
{ keep = "name"; }
{ keep = "target"; }
{ keep = "requires"; }
{ keep = "resolvedPath"; }
{ insert = "order"; type = H.nat; }
];
};
synth.constructors.aspect.fields.order = _ctx: 1;
};
Materialized = G.ornaments.composeFunctional MaterializedAspect ResolvedAspect;
in G.ornaments.build Materialized aspect
The composed section builds the final enriched value directly from the base aspect. Forgetting the result through the composed ornament returns the base aspect.
Lifting producers and transforms
Producer lifting runs a base producer, then enriches its output through the functional section:
let
makeAspect =
H.ann
(H.lam "aspect" Aspect.T (_:
G.value.review Aspect.T {
_con = "aspect";
name = "workspace-shell";
target = "module";
requires = [ "toolchain" ];
}))
(H.forall "_" Aspect.T (_: Aspect.T));
lifted = G.ornaments.liftProducer Materialized makeAspect aspect;
in G.value.view MaterializedAspect.meta.ornamented.T lifted
The base function still only knows about Aspect. The lifted producer
returns the canonical materialized aspect. Forgetting the lifted result gives
the base producer's result.
Transform lifting is similar, but starts from an ornamented input:
G.ornaments.liftTransform {
input = ResolvedAspect;
output = MaterializedAspect;
fn = baseTransform;
} resolved
The input is forgotten to the base, fn runs on the base value, and the
output section rebuilds the canonical enriched result.
Obligations and diagnostics
Inserted fields must have a builder, a declared measure, or an explicit proof builder. Validation is total and returns structured diagnostics:
G.ornaments.validateFunctional {
base = Aspect;
spec = {
name = "BrokenResolvedAspect";
constructors.aspect.fields = [
{ keep = "name"; }
{ keep = "target"; }
{ keep = "requires"; }
{ insert = "resolvedPath"; type = H.string; }
];
};
synth = {};
}
The diagnostic identifies the missing field builder:
{
ok = false;
diagnostics = [{
code = "functional.missing-builder";
path = [ "functional" "constructors" "aspect" "fields" "resolvedPath" ];
message = "inserted field needs builder";
severity = "error";
}];
}
Proof-marked inserted fields use proof = true or role = "proof" and
report functional.unresolved-proof until a proof builder is supplied.
Measure-derived inserted fields use measure = "<name>" and require a
matching entry under measures.
Algebraic ornaments
Algebraic ornaments are the case where inserted indices or fields are computed from a fold over the base value. The canonical example is ornamenting a list into a vector by inserting its length.
The public helper is G.ornaments.algOrn. It validates the supported
description/algebra fragment before constructing the ornament. The
current supported fragment covers ret, arg, rec, keep-only pi,
and plus. Unsupported function-domain aggregation remains explicit:
validation reports a shape diagnostic instead of guessing an algebra.
Use algebraic ornaments when the extra information is determined by the base structure itself. Use functional ornaments when the extra information comes from a user-provided synthesis function, proof builder, or declared measure.
Ornament composition
Plain, functional, and algebraic ornaments all refine a single base datatype at the whole-type level. Three additional constructions refine ornaments themselves, so the refinement at one field position can be an ornament rather than the identity:
- Leaf functional ornaments lift a Nix-meta function
f : B → Ainto an ornament of a primitive HOAS type former. They are the base case for refinements on non-μ types (H.string,H.derivation,H.thunk, …) where there is no constructor structure to walk. - Functorial container lifts carry an ornament
O : Ornament A A'through a strictly-positive container functor (List,Attrs,Maybe, or a single record field) to produce an ornament between the lifted containers, with forget defined by the standard functorial action. - Sub-ornament composition at
keepis the spec verb{ keep = "x"; sub = O; }inside aG.ornaments.ornamentcall. It declares that the kept field carries the refined type at the source and forgets throughO.forgetat that field on the way to the base.
These compose: a keep + sub may use a lift.list of a
thunkOrnament, and so on.
Leaf functional ornaments
A leaf functional ornament refines a primitive HOAS type former by
attaching a Nix-meta function pair (forget, section). Construction
requires the primitive to satisfy _htag ≠ "mu"; μ-encoded bases use
H.ornament or H.functionalOrnament instead.
let
fx = import ./nix/nix-effects {};
H = fx.types.hoas;
IdString = H.leafOrnament {
primitive = H.string;
forget = x: x;
section = x: x;
};
in IdString.forget "hello"
The identity is the trivial case; the construction earns its keep when
forget is a non-trivial Nix function such as forceThunk. The
literature anchor is Dagand and McBride, Transporting Functions
across Ornaments (JFP 2014), which gives the general construction of
a functional ornament over A induced by a function f : B → A. The
leaf case specialises that construction to primitive type formers.
The derived constructor H.thunkOrnament packages the lift of
forceThunk : Thunk a → a and its section mkThunk : a → Thunk a:
H.thunkOrnament inner
≡ H.leafOrnament {
primitive = H.thunk inner;
forget = fx.state.thunk.forceThunk;
section = fx.state.thunk.mkThunk;
meta = { name = "Thunk"; baseHtag = "thunk"; inner = inner; };
}
The Thunk carrier is the kernel's deepSeq-safe wrapper for transport
of cyclic or otherwise force-unsafe values through handler state; see
src/state/thunk.nix for the carrier shape and discipline. H.thunk
is the leaf functional ornament whose forget is forceThunk — the
ornament algebra is the principled home for the Thunk → a story
that the runtime already follows.
Functorial container lifts
Given an ornament O : Ornament A A', each strictly-positive
container functor F extends O to an ornament F(O) : Ornament
F(A) F(A') whose forget is F's functorial action on O.forget.
The kernel exposes four lifts under G.ornaments.lift:
G.ornaments.lift.list : Ornament A A' -> Ornament (List A) (List A')
G.ornaments.lift.attrs : Ornament A A' -> Ornament (Attrs A) (Attrs A')
G.ornaments.lift.maybe : Ornament A A' -> Ornament (Maybe A) (Maybe A')
G.ornaments.lift.field : String -> Ornament A A' -> Ornament Record Record'
Forget is map o.forget, mapAttrs (_: o.forget), the null-passthrough
of o.forget, and the single-field rewrite r // { ${name} = o.forget
r.${name}; } respectively. Section mirrors forget on the dual side.
let
fx = import ./nix/nix-effects {};
H = fx.types.hoas;
G = fx.types.generic;
drvA = { type = "derivation"; name = "a"; outPath = "/nix/store/a"; };
drvB = { type = "derivation"; name = "b"; outPath = "/nix/store/b"; };
thunked = H.thunkOrnament H.derivation;
paths = G.ornaments.lift.list thunked;
in paths.forget [ (thunked.section drvA) (thunked.section drvB) ]
# ≡ [ drvA drvB ]
The literature anchor is Dagand, A Cosmology of Datatypes (PhD
thesis, Strathclyde 2013), chapter 6 "Functoriality of refinements." The
four lifts are siblings, not derived from one another at the kernel
boundary: each is the functorial action of an ornament along the
corresponding container functor. lift.field is the elementary
product-component move; the other three are the analogous moves for
List, Attrs, and Maybe. The container lifts decompose through
lift.field via the μ-encoding of containers, but the kernel ships
them all directly so consumers do not pay μ-unfolding cost at every
use site.
Sub-ornament composition at keep
The spec language of G.ornaments.ornament recognises
{ keep = "fieldName"; sub = O; } as a directive that the named
field carries O's refined type on the ornamented side and
forgets through O.forget at that field. The plain { keep =
"fieldName"; } form remains the categorical identity at the kept
position; the sub verb adds the refinement.
let
fx = import ./nix/nix-effects {};
H = fx.types.hoas;
G = fx.types.generic;
drv = { type = "derivation"; name = "x"; outPath = "/nix/store/x"; };
Box = H.datatype "Box" [
(H.con "box" [ (H.field "value" H.derivation) ])
];
TaggedThunkBox = G.ornaments.ornament Box {
name = "TaggedThunkBox";
constructors.box.fields = [
{ keep = "value"; sub = H.thunkOrnament H.derivation; }
{ insert = "tag"; type = H.bool; }
];
};
state = {
_con = "box";
value = (H.thunkOrnament H.derivation).section drv;
tag = true;
};
in G.ornaments.forget TaggedThunkBox state
# ≡ { _con = "box"; value = drv; }
The forget walker copies plain keep fields, drops insert fields,
and dispatches keep + sub fields through the sub-ornament's forget.
Constructor _con is preserved because the kernel reuses the base
constructor's name for the refined branch.
Sub-type agreement is checked at spec time. A sub whose forward
type disagrees with the kept field's type produces an
ornament.sub-type-mismatch diagnostic; a sub that is not a
recognised ornament produces ornament.sub-not-ornament.
A single forget, two evaluations
G.ornaments.forget : Ornamented → Value → Value is one morphism. The
HOAS-term path and the Nix-meta path are two evaluations of the same
morphism; the commutation eval (forgetHoas t) ≡ forgetMeta (eval t)
is a theorem of the framework. Dispatch is structural on the
well-formed sum _htag (HOAS term) ⊕ _con (Nix-meta μ-value):
G.ornaments.forget ornamented value
# value._htag -> HOAS-term path (returns HOAS-applied forget)
# value._con -> Nix-meta path (returns Nix attrset)
# both / neither / non-attrset -> throws
Both internal paths are reachable as
G.ornaments._internal.{forgetHoas, forgetMeta} for tests that need
to pin the evaluator, but application code calls G.ornaments.forget
and lets the dispatcher pick.
API summary
HOAS layer:
H.ornament base spec
H.ornDesc ornament
H.ornForget ornament
H.ornCompose outer inner
H.ornPullback ornament resultTy baseFn
H.ornLiftFold ornament resultTy baseFold
H.functionalOrnament { ornament; chooseIndex; section; ... }
H.ornBuild functional index baseValue
H.ornLiftProducer functional baseFn index baseInput
H.ornLiftTransform { input; output; fn; } outputIndex inputIndex ornamentedInput
H.leafOrnament { primitive; forget; section; sectionProof?; meta?; }
H.thunkOrnament inner
Generic layer:
G.ornaments.ornament base spec
G.ornaments.forget ornamented value
G.ornaments.compose outer inner
G.ornaments.pullback ornamented baseFn value
G.ornaments.functional { base; spec; synth; ... }
G.ornaments.validateFunctional { base; spec; synth; ... }
G.ornaments.build functional baseValue
G.ornaments.composeFunctional outer inner
G.ornaments.liftProducer functional baseFn baseInput
G.ornaments.liftTransform { input; output; fn; } ornamentedInput
G.ornaments.lift.list o
G.ornaments.lift.attrs o
G.ornaments.lift.maybe o
G.ornaments.lift.field name o
The generic layer is the best default for application code. Drop to HOAS when you need direct indexed terms, explicit proof terms, or kernel-level transport.