# nix-effects — Full Documentation ## Guide ### Introduction Nix configurations fail late. A misspelled option name, a string where an integer belongs, a firewall rule that references a port no service listens on — these surface at build time, at deploy time, or when a user files a ticket. The NixOS module system catches some of this with `mkOption` and `types.str`, but the checking is shallow: it validates individual fields, not relationships between them. "The build system must appear in the declared platforms list" is a constraint no existing Nix tool can express as a type. nix-effects is a freer-monad effect layer for pure Nix, with a dependent type checker built on top of it. The effect layer is where the DX comes from. Validation is phrased as a `typeCheck` effect, and the same validator can run under different handlers that choose what happens on a failure. The handler is where the policy lives, not the validator. Everything runs at `nix eval` time, before anything builds or ships. On top of the effect layer sits a Martin-Löf dependent type checker in `src/tc/` with Pi, Sigma, identity types with J, cumulative universes, HOAS elaboration, and verified extraction of plain Nix functions from proof terms. The kernel itself is pure functions over values, independent of the effect layer; the effect layer is what surfaces kernel errors to users. The bidirectional checker sends `typeCheck` effects carrying a field-path context, so type errors in deeply nested terms come back localized to the field that broke. ## What it looks like A port number is an integer between 1 and 65535. In nix-effects, that's a refinement type: ```nix let inherit (fx.types) Int refined; Port = refined "Port" Int (x: x >= 1 && x <= 65535); in { ok = Port.check 8080; # true bad = Port.check 99999; # false } ``` Behind the scenes, `.check` runs the MLTT kernel's decision procedure — the value is elaborated into a kernel term, type-checked, and the predicate is evaluated. For a refinement type like `Port`, this is fast: the kernel confirms the base type (`Int`), the guard confirms the range. You write normal Nix and the kernel runs behind the scenes. But checking individual values is only the starting point. The kernel can also verify entire functions — confirm that a validator you wrote is type-correct, then extract it as an ordinary Nix function. ## Verified functions over real data Write an implementation in HOAS (Higher-Order Abstract Syntax), the kernel type-checks it, and `v.verify` extracts a callable Nix function. Here's a derivation spec validator. The kernel verifies a function that takes a record with `license`, `platforms`, and `system` fields, then checks three constraints: the build system appears in the platforms list, the system is one of the supported architectures, and the license is approved. All three checks use string comparison inside the kernel — `strEq` is a kernel primitive, not a Nix-level hack. ```nix let H = fx.types.hoas; v = fx.types.verified; Spec = H.record [ { name = "license"; type = H.string; } { name = "platforms"; type = H.listOf H.string; } { name = "system"; type = H.string; } ]; licenses = mkStrList [ "MIT" "Apache-2.0" "BSD-3-Clause" ]; systems = mkStrList [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" ]; # Kernel-verified: system ∈ platforms AND system ∈ supported AND license ∈ approved validateSpec = v.verify (H.forall "s" Spec (_: H.bool)) (v.fn "s" Spec (s: v.if_ H.bool (v.strElem (v.field Spec "system" s) (v.field Spec "platforms" s)) { then_ = v.if_ H.bool (v.strElem (v.field Spec "system" s) systems) { then_ = v.strElem (v.field Spec "license" s) licenses; else_ = v.false_; }; else_ = v.false_; })); in { ok = validateSpec { system = "x86_64-linux"; license = "MIT"; platforms = [ "x86_64-linux" "aarch64-linux" ]; }; # true bad = validateSpec { system = "arm64"; license = "MIT"; platforms = [ "x86_64-linux" ]; }; # false — arm64 not in supported systems mismatch = validateSpec { system = "x86_64-linux"; license = "MIT"; platforms = [ "aarch64-linux" ]; }; # false — system not in its own platforms list } ``` `validateSpec` is a plain Nix function. You call it with a plain Nix attrset. But the implementation was verified by the MLTT kernel before extraction — the kernel confirmed that the function matches its type (`Spec → Bool`), that field projections are well-typed, and that the string membership checks compose correctly. If you made a type error in the implementation — say, compared a `Bool` where a `String` was expected — the kernel would reject it at `nix eval` time. The record type (`H.record`) elaborates to nested Sigma in the kernel. `v.field` desugars to the right chain of first/second projections. `v.strEq` is a kernel primitive that reduces `strEq "foo" "foo"` to `true` during normalization. `v.strElem` folds over a list with `strEq`. None of this is Nix-level string comparison — it's computation inside the proof checker. ## Proofs as programs The same kernel that verifies functions also checks mathematical proofs. Both are the same judgment — `Γ ⊢ t : T` — applied differently. A verified function proves that an implementation inhabits its type. An equality proof proves that two expressions reduce to the same normal form. ```nix let H = fx.types.hoas; v = fx.types.verified; # Verified addition: Nat → Nat → Nat by structural recursion add = v.verify (H.forall "m" H.nat (_: H.forall "n" H.nat (_: H.nat))) (v.fn "m" H.nat (m: v.fn "n" H.nat (n: v.match H.nat m { zero = n; succ = _k: ih: H.succ ih; }))); in { five = add 2 3; # 5 # Prove 3 + 5 = 8: the kernel normalizes both sides, Refl witnesses equality proof = (H.checkHoas (H.eq H.nat (add (H.natLit 3) (H.natLit 5)) (H.natLit 8)) H.refl).tag == "refl"; # true } ``` The `add` function is extracted exactly like `validateSpec` — write in HOAS, kernel checks, extract a Nix function. The equality proof goes one step further: the kernel normalizes `add(3, 5)` by running the structural recursion, arrives at `8`, and confirms `Refl` witnesses `8 = 8`. This is computational proof — the kernel computes the answer and verifies that computation agrees with the claim. ## The effect system The "effects" in nix-effects are algebraic effects implemented via a freer monad (Kiselyov & Ishii 2015). A computation is a tree of effects with continuations. A handler walks the tree, interpreting each effect: ```nix let inherit (fx) pure bind run; inherit (fx.effects) get put; # Read state, double it, write it back doubleState = bind get (s: bind (put (s * 2)) (_: pure s)); result = run doubleState fx.effects.state.handler 21; # result.value = 21, result.state = 42 in result ``` This matters for type checking because it separates *what* to check from *how* to report. When `DepRecord.validate` finds a type error, it sends a `typeCheck` effect. The handler decides the policy: - **Strict** — abort on the first error - **Collecting** — gather all errors, keep checking - **Logging** — record every check, pass or fail Same validation logic, different handler. The type-checking kernel itself runs as an effectful computation on this same infrastructure — the kernel is just another program running on the effects substrate. ## The verification spectrum Not everything needs proofs. nix-effects supports four levels of assurance, and you pick the one that fits: **Level 1 — Contract.** Write normal Nix. Types check values via `.check`. The kernel runs behind the scenes. Zero cost to adopt. ```nix Port = refined "Port" Int (x: x >= 1 && x <= 65535); Port.check 8080 # true ``` **Level 2 — Boundary.** Data is checked by the kernel at module interfaces. Every type has a `kernelType` and `.check` is derived from the kernel's `decide` procedure. This is what all built-in types do by default — `(ListOf String).check ["a" "b"]` elaborates the list into a kernel term and type-checks it. **Level 3 — Property.** Write proof terms in HOAS that the kernel verifies. Prove that `3 + 5 = 8`, or that double negation on booleans is the identity, or that `append([1,2], [3]) = [1,2,3]`. **Level 4 — Full.** The implementation IS the proof term. Write in HOAS, the kernel verifies, `extract` produces a Nix function correct by construction. The `validateSpec` example above is Level 4 — the kernel verified the validator before extracting it as a callable function. Most users will stay at levels 1 and 2. The kernel is there when you need it. With record types and string comparison now in the kernel, Level 4 handles real-world validation — not just arithmetic on natural numbers. ## How this document is organized The rest of the guide builds up from here: - **[Getting Started](/nix-effects/guide/getting-started)** walks through installation, your first type, your first effect, and the end-to-end derivation demo. - **[Proof Guide](/nix-effects/guide/proof-guide)** builds proofs incrementally, from computational equality through the J eliminator to verified extraction of plain Nix functions from kernel-checked HOAS terms. - **[Theory](/nix-effects/guide/theory)** covers the papers that shaped the design, algebraic effects and freer monads, FTCQueue for O(1) bind, dependent type theory in the Martin-Löf and Mini-TT lineage, the handler pattern, and refinement and graded types, and how they compose as a practical engineering layer with a dependent type checker on top. - **[Trampoline](/nix-effects/guide/trampoline)** explains how `builtins.genericClosure` becomes a trampoline for stack-safe evaluation at scale. - **[Systems Architecture](/nix-effects/guide/systems-architecture)** describes the kernel-first design: one notion of type, one checking mechanism, no adequacy bridge. - **[Kernel Architecture](/nix-effects/guide/kernel-architecture)** details the MLTT kernel internals — NbE, bidirectional checking, HOAS elaboration, extraction, and the trust model. - **[Kernel Specification](/nix-effects/guide/kernel-spec)** gives the formal typing rules. ## References 1. Martin-Lof, P. (1984). *Intuitionistic Type Theory*. Bibliopolis. 2. Kiselyov, O., & Ishii, H. (2015). *Freer Monads, More Extensible Effects*. Haskell Symposium 2015. [[pdf](https://okmij.org/ftp/Haskell/extensible/more.pdf)] 3. Plotkin, G., & Pretnar, M. (2009). *Handlers of Algebraic Effects*. ESOP 2009. [[doi](https://doi.org/10.1007/978-3-642-00590-9_7)] 4. Findler, R., & Felleisen, M. (2002). *Contracts for Higher-Order Functions*. ICFP 2002. [[doi](https://doi.org/10.1145/581478.581484)] ### Getting Started ## Installation Add nix-effects as a flake input: ```nix { inputs.nix-effects.url = "github:kleisli-io/nix-effects"; outputs = { nix-effects, nixpkgs, ... }: let fx = nix-effects.lib; in { # fx.types, fx.run, fx.send, fx.bind, fx.effects, fx.stream ... }; } ``` Or import directly without flakes: ```nix let fx = import ./path/to/nix-effects { lib = nixpkgs.lib; }; in ... ``` ## Your first type Define a type with `fx.types.refined`: ```nix let inherit (fx.types) Int refined; Port = refined "Port" Int (x: x >= 1 && x <= 65535); in { # Kernel decision procedure — fast boolean check ok = Port.check 8080; # true bad = Port.check 99999; # false # Effectful validate — runs through the trampoline, produces blame context result = fx.run (Port.validate 99999) fx.effects.typecheck.collecting []; # result.state = [ { context = "Port"; message = "Expected Port, got int"; ... } ] } ``` ## Your first dependent type One field's type depends on another field's value — this is a genuine dependent type, checked by the MLTT proof-checking kernel: ```nix let inherit (fx.types) Bool Int String ListOf DepRecord refined; FIPSCipher = refined "FIPSCipher" String (x: builtins.elem x [ "AES-256-GCM" "AES-192-GCM" "AES-128-GCM" "AES-256-CBC" "AES-128-CBC" ]); ServiceConfig = DepRecord [ { name = "fipsMode"; type = Bool; } { name = "cipherSuites"; type = self: if self.fipsMode then ListOf FIPSCipher else ListOf String; } ]; in { ok = ServiceConfig.checkFlat { fipsMode = true; cipherSuites = [ "AES-256-GCM" ]; }; # true bad = ServiceConfig.checkFlat { fipsMode = true; cipherSuites = [ "3DES" ]; }; # false } ``` ## Your first effect Write a computation, then choose the handler: ```nix let inherit (fx) pure bind run; inherit (fx.effects) get put; # Double the state doubleState = bind get (s: bind (put (s * 2)) (_: pure s)); result = run doubleState fx.effects.state.handler 21; # result.value = 21 (old state returned) # result.state = 42 (state doubled) in result ``` ## Running the showcase The repo includes a working end-to-end demo: ```bash git clone https://github.com/kleisli-io/nix-effects cd nix-effects # Valid config — build succeeds nix build .#cryptoService # Invalid config (3DES in FIPS mode) — caught at eval time nix build .#buggyService # error: Type errors in ServiceConfig: # - List[FIPSCipher][3]: "3DES" is not a valid FIPSCipher # Run all tests nix flake check ``` ## The kernel behind every type Every `.check` call runs the MLTT type-checking kernel. When you write `Port.check 8080`, the kernel elaborates `8080` into a term, type-checks it, and returns a boolean. This is not a separate system — it is what `.check` does. You can also write verified implementations using HOAS combinators: ```nix let H = fx.types.hoas; v = fx.types.verified; # Write a function in HOAS, kernel type-checks it, extract as Nix function succ = v.verify (H.forall "x" H.nat (_: H.nat)) (v.fn "x" H.nat (x: H.succ x)); in succ 5 # 6 — a certified Nix function ``` The kernel checks the implementation against its type at `nix eval` time. If the types don't match, you get an error before anything builds. See the [Kernel Architecture](/nix-effects/guide/kernel-architecture) chapter for the full pipeline. ## What's in the box The `fx` attrset is the entire public API: | Namespace | Contents | |-----------|---------| | `fx.pure`, `fx.bind`, `fx.send`, `fx.map`, `fx.seq` | Freer monad kernel | | `fx.run`, `fx.handle` | Trampoline interpreter | | `fx.adapt`, `fx.adaptHandlers` | Handler composition | | `fx.types.*` | Type system (primitives, constructors, dependent, refinement, universe) | | `fx.types.hoas` | HOAS surface combinators for the kernel | | `fx.types.elaborateType`, etc. | Elaboration bridge: fx.types ↔ kernel | | `fx.types.verified` | Convenience combinators for writing verified implementations | | `fx.effects.*` | Built-in effects (state, error, reader, writer, acc, choice, conditions, typecheck, linear) | | `fx.stream.*` | Effectful lazy sequences | ### Sugar nix-effects ships an opt-in syntax layer. The kernel doesn't import it, nothing in the effect interpreter depends on it, and removing it leaves the library unchanged. What it buys is readability. A three-step state computation without sugar: ```nix bind state.get (n: bind (state.put (n + 1)) (_: bind state.get (n2: pure n2))) ``` and with sugar, two forms: ```nix # Combinator do [ (_: state.get) (n: state.put (n + 1)) (_: state.get) ] # Operator state.get / (n: state.put (n + 1)) / (_: state.get) ``` Both evaluate to the same value under the state handler. A third form — `letM` — applies to parallel effects whose results you want under named bindings: ```nix # Without sugar bind (reader.asks (e: e.host)) (host: bind (reader.asks (e: e.port)) (port: pure "${host}:${toString port}")) # With letM letM { host = reader.asks (e: e.host); port = reader.asks (e: e.port); } (b: pure "${b.host}:${toString b.port}") ``` `letM` evaluates its attrs independently and passes the result attrset to the continuation. Use it when the effects don't depend on each other's values. ## Opting in Sugar is a hybrid namespace. Effect combinators sit at the top level of `fx.sugar`, so `with fx.sugar;` brings `do`, `letM`, `pure`, `bind`, `run`, and `handle` into scope immediately. Division and types are one level deeper, under `operators` and `types` respectively. ``` fx.sugar ├── do, letM ├── pure, bind, map, seq, pipe, kleisli ├── run, handle ├── operators │ └── __div └── types ├── wrap └── Int, String, Bool, Float, Path, Null, Unit, Any ``` The combinator-only form is safe under every Nix dialect. No operator magic, no `with`, no chance of surprising anyone: ```nix let inherit (fx.sugar) do letM; in do [ (_: state.get) (n: state.put (n * 3)) (_: state.get) ] ``` Adding `/` as left-associative bind turns long `bind` chains into pipelines: ```nix let inherit (fx.sugar.operators) __div; in state.get / (s: state.put (s + 7)) / (_: state.get) ``` For a file that's mostly computation, reach for `with fx.sugar;` and pair it with the `__div` inherit: ```nix let inherit (fx.sugar.operators) __div; in with fx.sugar; state.get / (s: state.put (s * 2)) / (_: state.get) ``` The division operator is always nested under `operators`. `with fx.sugar;` alone will not activate `/` — you have to reach for `operators` explicitly. That nesting is the entire reason the namespace is hybrid. ## Effect combinators ### `do`: sequence of steps `do` takes a list of functions, each of which receives the previous step's value and returns the next computation. The first step gets `null`: ```nix do [ (_: pure 1) (x: pure (x + 1)) (x: pure (x * 10)) ] # runs to 20 ``` Empty lists produce `pure null`. Singletons are equivalent to calling the one step on `null`. The argument binding is positional: `(n: ...)` means "the previous step's value is bound to `n`." If you don't need it, use `_`. ### `letM`: named results `letM` collects an attrset of computations, evaluates each one, and hands the result attrset to a continuation. The Reader-pattern example from the test suite: ```nix letM { host = reader.asks (e: e.host); port = reader.asks (e: e.port); } (b: pure "${b.host}:${toString b.port}") # runs to "example.com:443" ``` The continuation receives `{ host; port; }`. When a computation and its continuation don't need sequencing by intermediate values but do need named results in scope, `letM` is cleaner than nested `bind`. ### `__div`: operator-style bind `__div` is a magic attribute name. When both operands of `/` are non-numeric and `__div` is lexically in scope, Nix dispatches the operator through it. `fx.sugar.operators.__div` is `fx.bind` under another name. ```nix let inherit (fx.sugar.operators) __div; in state.get / (n: pure (n + 1)) / (n: pure (n * 2)) ``` The form is left-associative: `a / f / g` is `bind (bind a f) g`. This matches the usual reading of a pipeline. ### Re-exports For convenience, `fx.sugar` re-exports `pure`, `bind`, `map`, `seq`, `pipe`, `kleisli`, `run`, and `handle` verbatim from `fx`. `with fx.sugar;` gives you everything the effect layer exposes without a second `inherit` line. ## Type sugar ### Primitives and refinement `fx.sugar.types` pre-wraps the eight zero-ary primitives — `Int`, `String`, `Bool`, `Float`, `Path`, `Null`, `Unit`, `Any` — with a `__functor` that builds a refinement when you apply a predicate. A port number, written without sugar: ```nix let inherit (fx.types) Int refined; in refined "Port" Int (x: x >= 0 && x <= 65535) ``` and with sugar: ```nix let inherit (fx.sugar.types) Int; in Int (x: x >= 0) (x: x <= 65535) ``` Both produce a kernel-identical type. The difference is readability when you're composing several predicates. ### Name cascading Every refinement appends a `?` to the base type's name. Repeated refinement cascades: ```nix let inherit (fx.sugar.types) Int; P0 = Int; # "Int" P1 = Int (x: x >= 0); # "Int?" P2 = Int (x: x >= 0) (x: x < 10); # "Int??" in builtins.toString P2 # "Int??" ``` The name is what shows up in error messages. If `Int??` isn't descriptive enough, drop back to `fx.types.refined` and give the type an explicit name: ```nix let inherit (fx.types) refined Int; in refined "Port" Int (x: x >= 0 && x <= 65535) ``` ### `wrap` for user-defined types Types built with `fx.types.mkType` don't get sugar by default. Wrap them with `fx.sugar.types.wrap` to opt in: ```nix let inherit (fx.types) mkType hoas; inherit (fx.sugar.types) wrap; UserInt = mkType { name = "UserInt"; kernelType = hoas.int_; }; Sugared = wrap UserInt; in (Sugared (x: x > 0)).check 5 # true ``` Wrapping is purely additive. It only adds `__functor` (for refinement application) and `__toString` (for the name). The base type's kernel, check, description, universe, and every other field stay untouched — so a sugared type is interchangeable with the desugared original everywhere the kernel looks at it. ### Sugar inside Record fields Constructors like `Record`, `ListOf`, `Maybe`, and `Either` already consume a first argument — their schema. Wrapping them with `__functor` would collide with that call shape, so `fx.sugar.types` doesn't wrap them. It doesn't need to: a sugared field-type inside a Record schema composes for free, because Record reads only the kernel, which sugar preserves: ```nix let inherit (fx.types) Record; inherit (fx.sugar.types) Int String Bool; in Record { age = Int (x: x >= 0); name = String (s: builtins.stringLength s > 0); active = Bool; } ``` This Record has the same `_kernel` as the hand-refined version. The sugar is pushed *into* the schema, where it needs no special support from the constructor. ## Caveats A few details worth knowing before you reach for sugar. ### `+` can't be overloaded Nix's `+` operator is `ExprConcatStrings` in the parser 1. The runtime dispatches on operand types (string, path, number) without consulting any magic attribute. There's no `__plus` to implement. Applied to two types, `+` will either concatenate (if they're strings), add (if they're numbers), or error out. It is not a hook. For the same reason, there's no way to overload `==`, `<`, or most other operators. Sugar uses what Nix already dispatches through: `__functor` for callable attrsets, `__toString` for string coercion, and `__div` for `/`. ### `with` does not activate `__div` Nix's `/` operator looks up `__div` by name in the enclosing lexical scope. It does not search `with`-scoped values. This surprises people who expect the two forms to be interchangeable: ```nix # Works: let inherit (fx.sugar.operators) __div; in (state.get / f) # Does NOT work — raises an arithmetic division error at runtime: with fx.sugar.operators; (state.get / f) ``` The reason is that `with` only extends the free-variable lookup chain — it does not introduce `__div` as a bound name in the scope that `/` consults. The full-sugar form above wraps `inherit (fx.sugar.operators) __div;` in the same `let` that brings in the combinators for exactly this reason. A witness test lives at `tests/sugar-effects-test.nix` under `withOperatorsDoesNotActivateDiv`. It asserts that `with fx.sugar.operators; (6 / 2) == 3` — plain arithmetic, not `__div` dispatch. ### Lix 2.92+ rejects `__div` shadow Lix 2 deprecated the pattern of binding a name prefixed with `__` that shadows a builtin-reserved operator slot. As of Lix 2.92, `let inherit (ops) __div; in ...` produces `shadow-internal-symbols` errors during parse. If your codebase targets Lix, stick to `do` and `letM` — they don't touch this mechanism. This is not a CppNix limitation. `__div` works under CppNix 2.18+ and 2.31 (the release we test against). The Lix deprecation is a deliberate policy choice in that fork. ### Scope pollution with `with` `with fx.sugar.operators;` brings `__div` into the lookup chain as a value, not as an operator hook. As just noted, it won't make `/` dispatch to it. But it does make the name `__div` available for reference — a minor footgun if you were relying on shadowing. Prefer `inherit` over `with` for operator opt-in. ### Name cascading versus explicit names Chained refinements produce names like `Int??`. That's intentional ("you refined this twice") but not always helpful in error messages. If your domain has a real name, use `fx.types.refined` directly: ```nix let inherit (fx.types) refined Int; Even = refined "Even" Int (x: builtins.bitAnd x 1 == 0); Positive = refined "Positive" Int (x: x > 0); EvenPositive = refined "EvenPositive" Even (x: x > 0); in EvenPositive.name # "EvenPositive" ``` Sugar is for in-place predicates, not named types that outlive their definition. ## Forward-compat notes Sugar is strictly additive and never references anything the type system marks for retirement. Three commitments hold across future changes to the kernel and type modules. **Kernel preservation.** A sugared type has the same `_kernel` as its base. Constructors (`Record`, `ListOf`, `Maybe`, `Either`, `Variant`) read only `_kernel` — so a sugared field is indistinguishable from a desugared one to every kernel consumer. **Refinement delegation.** Sugar never constructs refined types directly. Every `sugared T (pred)` call goes through `fx.types.refined`, which is the user-facing API point guaranteed to survive kernel-internal reorganizations. If `refined` changes shape, sugar follows automatically. **No diagnostic emission.** Sugar never builds values from `src/diag/positions.nix` or `src/diag/error.nix`. Error annotation happens via the base type's `description` and `name`, which propagate through `refined` without sugar-specific code. When the diagnostic layer gains structure, sugar will inherit it. Active witness tests for each of these live in `tests/sugar-compat-test.nix`. Running the test file as part of `nix flake check` keeps the commitments observable. ## When to reach for which form `do` and `letM` are the default. Reach for `__div` when a pipeline has three or more obvious-effect steps and the parentheses are hurting readability. Reach for `with fx.sugar;` when you're writing a file that's mostly computation, not mostly plumbing. For types, use `fx.sugar.types` for one-off refinements inside Record schemas. Drop back to `fx.types.refined` when the type deserves a name you'll reference elsewhere. --- 1 `src/libexpr/parser.y` in the Nix source, handling `ExprConcatStrings`. 2 Lix is a community fork of Nix. Relevant deprecation: `shadow-internal-symbols` in Lix 2.92 release notes. ### Proof Guide Nix configurations are concrete at eval time. Every field, every value, every list element is known before anything builds. The nix-effects dependent type checker exploits this: it normalizes both sides of an equation via NbE, and if they reduce to the same value, `Refl` proves them equal. No symbolic reasoning, no induction over unknowns — just computation on concrete data, checked through the freer-monad effect layer in 1,300 lines of pure Nix. This chapter builds proofs incrementally, from `0 + 0 = 0` through the J eliminator to verified extraction of plain Nix functions from kernel-checked HOAS terms. Every example is runnable. The code comes from three files in the repository: [`proof-basics.nix`](https://github.com/kleisli-io/nix-effects/blob/main/examples/proof-basics.nix), [`equality-proofs.nix`](https://github.com/kleisli-io/nix-effects/blob/main/examples/equality-proofs.nix), and [`verified-functions.nix`](https://github.com/kleisli-io/nix-effects/blob/main/examples/verified-functions.nix). **Prerequisites.** You should know what a function is and what `let` bindings do in Nix. Familiarity with the Getting Started chapter helps but isn't required. You do not need to know type theory. ## Your first proof A proof in nix-effects is a term that type-checks against an equality type. The simplest equality type is `Eq(Nat, 0+0, 0)` — the claim that adding zero to zero produces zero. The proof term is `Refl`, which says "both sides are the same." The kernel checks this by normalizing `0 + 0`, arriving at `0`, and confirming that `Refl` witnesses `0 = 0`. ```nix let H = fx.types.hoas; inherit (H) nat eq zero refl checkHoas; # Addition by structural recursion on the first argument add = m: n: H.ind (H.lam "_" nat (_: nat)) n (H.lam "k" nat (_: H.lam "ih" nat (ih: H.succ ih))) m; in # Prove: 0 + 0 = 0 (checkHoas (eq nat (add zero zero) zero) refl).tag == "refl" # → true ``` `checkHoas` is the kernel's entry point. It takes a type and a term, runs bidirectional type checking with normalization by evaluation, and returns a result. If the result's `tag` is `"refl"`, the proof was accepted. If it has an `error` field, the kernel rejected it. The kernel doesn't pattern-match on `0 + 0 = 0` as a special case. It evaluates `add(zero, zero)` by running the `NatElim` eliminator — the base case fires, returns `n` (which is `zero`), and the kernel sees `Eq(Nat, zero, zero)`. `Refl` witnesses any `Eq(A, x, x)`, so the proof goes through. Larger numbers work the same way. The kernel unrolls the recursion: ```nix # 3 + 5 = 8 (checkHoas (eq nat (add (H.natLit 3) (H.natLit 5)) (H.natLit 8)) refl).tag == "refl" # 10 + 7 = 17 (checkHoas (eq nat (add (H.natLit 10) (H.natLit 7)) (H.natLit 17)) refl).tag == "refl" ``` Both reduce to `true`. The kernel normalizes `add(3, 5)` step by step — three `succ` peels, then the base case returns `5`, then three `succ` wrappers are reapplied — and confirms the result is `8`. ## Dependent witnesses A computational equality says "these two things are the same." A dependent witness says "here is a value, and here is evidence that it has a property." The Sigma type `Σ(x:A).P(x)` packages both: a value `x` of type `A`, and a proof that `P(x)` holds. ```nix let H = fx.types.hoas; inherit (H) nat eq sigma zero pair refl checkHoas; in { # "There exists x : Nat such that x = 0" — witnessed by (0, Refl) witness = let ty = sigma "x" nat (x: eq nat x zero); tm = pair zero refl; in (checkHoas ty tm).tag == "pair"; # → true } ``` The type `Σ(x:Nat). Eq(Nat, x, 0)` says "a natural number equal to zero." The term `(0, Refl)` inhabits it: `0` for the value, `Refl` for the proof that `0 = 0`. The kernel checks both components — it confirms `0 : Nat` and `Refl : Eq(Nat, 0, 0)`. Witnesses get more interesting when the property involves computation: ```nix # "There exists x such that 3+5 = x" — witnessed by (8, Refl) witnessAdd = let add = m: n: H.ind (H.lam "_" nat (_: nat)) n (H.lam "k" nat (_: H.lam "ih" nat (ih: H.succ ih))) m; ty = sigma "x" nat (x: eq nat (add (H.natLit 3) (H.natLit 5)) x); tm = pair (H.natLit 8) refl; in (checkHoas ty tm).tag == "pair"; ``` The kernel normalizes `add(3, 5)` to `8`, checks that `8` matches the witness value, and accepts the proof. If you claimed the witness was `7`, the kernel would reject it — `Refl` can't witness `8 = 7`. ## Eliminators Eliminators are how you compute over inductive types in type theory. Where Nix uses `if`/`else` and list folds, the kernel uses eliminators: structured recursion with a *motive* that declares what type the result has. The motive is what makes these dependently typed — the return type can vary based on the input. ### Booleans `H.boolElim k motive trueCase falseCase scrutinee` — case analysis on a derived boolean. `H.bool` is `μ ⊤ (plus (retI tt) (retI tt)) tt`, and `H.boolElim` is defined in terms of `desc-ind` on that description (see `src/tc/hoas/combinators.nix`). The user-facing behavior is the standard boolean eliminator: with a constant motive (return type doesn't depend on the boolean), it's equivalent to an if/else. ```nix let H = fx.types.hoas; inherit (H) nat bool eq zero refl boolElim checkHoas; in { # if true then 42 else 0 = 42 trueCase = let result = boolElim 0 (H.lam "_" bool (_: nat)) (H.natLit 42) zero H.true_; in (checkHoas (eq nat result (H.natLit 42)) refl).tag == "refl"; # if false then 42 else 0 = 0 falseCase = let result = boolElim 0 (H.lam "_" bool (_: nat)) (H.natLit 42) zero H.false_; in (checkHoas (eq nat result zero) refl).tag == "refl"; } ``` ### Natural numbers `NatElim(motive, base, step, n)` — structural recursion. The base case handles zero, the step case takes the predecessor `k` and the inductive hypothesis `ih` (the result for `k`) and produces the result for `S(k)`: ```nix let H = fx.types.hoas; inherit (H) nat eq refl checkHoas; # double(n): double(0) = 0, double(S(k)) = S(S(double(k))) double = n: H.ind (H.lam "_" nat (_: nat)) H.zero (H.lam "k" nat (_: H.lam "ih" nat (ih: H.succ (H.succ ih)))) n; in # double(4) = 8 (checkHoas (eq nat (double (H.natLit 4)) (H.natLit 8)) refl).tag == "refl" ``` The kernel unrolls four steps: `double(4) = S(S(double(3))) = ... = 8`. ### Lists `ListElim(elemType, motive, nilCase, consCase, list)` — structural recursion on lists. The nil case provides the base value, the cons case takes the head, tail, and inductive hypothesis: ```nix let H = fx.types.hoas; inherit (H) nat eq refl checkHoas; list123 = H.cons nat (H.natLit 1) (H.cons nat (H.natLit 2) (H.cons nat (H.natLit 3) (H.nil nat))); # sum(xs): fold with addition sumList = xs: H.listElim nat (H.lam "_" (H.listOf nat) (_: nat)) H.zero (H.lam "h" nat (h: H.lam "t" (H.listOf nat) (_: H.lam "ih" nat (ih: H.ind (H.lam "_" nat (_: nat)) ih (H.lam "k" nat (_: H.lam "ih2" nat (ih2: H.succ ih2))) h)))) xs; in # sum([1, 2, 3]) = 6 (checkHoas (eq nat (sumList list123) (H.natLit 6)) refl).tag == "refl" ``` ### Sums (coproducts) `SumElim(L, R, motive, leftCase, rightCase, scrutinee)` — case analysis on `Left(a)` or `Right(b)`: ```nix let H = fx.types.hoas; inherit (H) nat bool sum eq zero refl checkHoas; in { # case Left(5) of { Left n → n; Right _ → 0 } = 5 leftCase = let scrut = H.inl nat bool (H.natLit 5); result = H.sumElim nat bool (H.lam "_" (sum nat bool) (_: nat)) (H.lam "n" nat (n: n)) (H.lam "b" bool (_: zero)) scrut; in (checkHoas (eq nat result (H.natLit 5)) refl).tag == "refl"; } ``` ## The J eliminator Everything above uses `Refl` on equalities that the kernel verifies by computation — normalize both sides, confirm they match. But what if you want to reason *about* equalities? Prove that equality is symmetric, or that applying a function to equal inputs gives equal outputs? That requires the J eliminator, the fundamental proof principle for identity types in Martin-Löf type theory [1]. J says: if you can prove something about `x = x` (the reflexive case), you can prove it about any `x = y` where the equality is witnessed. ``` J(A, a, P, pr, b, eq) A : type a : left side of the equality P : λ(y:A). λ(_:Eq(A,a,y)). Type — the motive pr : P(a, refl) — the base case (when y = a) b : right side eq : Eq(A, a, b) — proof that a = b Returns: P(b, eq) Computation rule: J(A, a, P, pr, a, refl) = pr ``` When the equality proof is `Refl`, J returns the base case directly. The kernel reduces `J(..., refl)` to `pr`, and the proof goes through. ### Congruence If `x = y`, then `f(x) = f(y)` for any function `f`. This is the standard *cong* combinator, derived from J: ```nix let H = fx.types.hoas; inherit (H) nat eq u forall refl checkHoas; congType = forall "A" (u 0) (a: forall "B" (u 0) (b: forall "f" (forall "_" a (_: b)) (f: forall "x" a (x: forall "y" a (y: forall "_" (eq a x y) (_: eq b (H.app f x) (H.app f y))))))); congTerm = H.lam "A" (u 0) (a: H.lam "B" (u 0) (b: H.lam "f" (forall "_" a (_: b)) (f: H.lam "x" a (x: H.lam "y" a (y: H.lam "p" (eq a x y) (p: H.j a x (H.lam "y'" a (y': H.lam "_" (eq a x y') (_: eq b (H.app f x) (H.app f y')))) refl y p)))))); in (checkHoas congType congTerm).tag == "lam" ``` The derivation: J eliminates the proof `p : Eq(A, x, y)`. The motive says "given `y'` equal to `x`, produce `Eq(B, f(x), f(y'))`." In the base case, `y' = x`, so the goal is `Eq(B, f(x), f(x))` — which `Refl` proves. J then transports this to `Eq(B, f(x), f(y))`. This generic combinator type-checks with abstract variables `A`, `B`, `f`, `x`, `y` — the kernel verifies the reasoning is valid for all inputs. On concrete data, J receives `Refl` (since concrete equalities reduce by computation), and the kernel simplifies: ```nix # Concrete: from add(2,1) = 3, derive succ(add(2,1)) = succ(3) congConcrete = let add21 = add (H.natLit 2) (H.succ H.zero); three = H.natLit 3; in (checkHoas (eq nat (H.succ add21) (H.succ three)) (H.j nat add21 (H.lam "y" nat (y: H.lam "_" (eq nat add21 y) (_: eq nat (H.succ add21) (H.succ y)))) refl three refl)).tag == "j"; ``` ### Symmetry If `x = y`, then `y = x`. The motive is `λy'.λ_. Eq(A, y', x)` — when `y' = x`, the goal is `Eq(A, x, x)`, proved by `Refl`: ```nix # sym : Π(A:U₀). Π(x:A). Π(y:A). Eq(A,x,y) → Eq(A,y,x) symTerm = H.lam "A" (u 0) (a: H.lam "x" a (x: H.lam "y" a (y: H.lam "p" (eq a x y) (p: H.j a x (H.lam "y'" a (y': H.lam "_" (eq a x y') (_: eq a y' x))) refl y p)))); ``` ### Transitivity If `x = y` and `y = z`, then `x = z`. Fix `p : Eq(A, x, y)`, then eliminate `q` with J. The motive is `λz'.λ_. Eq(A, x, z')` — when `z' = y`, the goal is `Eq(A, x, y)`, proved by `p`: ```nix # trans : Π(A:U₀). Π(x:A). Π(y:A). Π(z:A). Eq(A,x,y) → Eq(A,y,z) → Eq(A,x,z) transTerm = H.lam "A" (u 0) (a: H.lam "x" a (x: H.lam "y" a (y: H.lam "z" a (z: H.lam "p" (eq a x y) (p: H.lam "q" (eq a y z) (q: H.j a y (H.lam "z'" a (z': H.lam "_" (eq a y z') (_: eq a x z'))) p z q)))))); ``` ### Transport The most general form. If `x = y` and `P(x)` holds, then `P(y)` holds. Congruence, symmetry, and transitivity are all special cases. ```nix # transport : Π(A:U₀). Π(P:A→U₀). Π(x:A). Π(y:A). Eq(A,x,y) → P(x) → P(y) transportTerm = H.lam "A" (u 0) (a: H.lam "P" (forall "_" a (_: u 0)) (bigP: H.lam "x" a (x: H.lam "y" a (y: H.lam "p" (eq a x y) (p: H.lam "px" (H.app bigP x) (px: H.j a x (H.lam "y'" a (y': H.lam "_" (eq a x y') (_: H.app bigP y'))) px y p)))))); ``` ### Chaining proofs J applications compose. Here we chain congruence (lift through `succ`) with symmetry (reverse the equality) — two J applications, the output of the first feeding as the equality proof to the second: ```nix # From Eq(Nat, add(2,1), 3): # Step 1 (cong succ): Eq(Nat, S(add(2,1)), S(3)) # Step 2 (sym): Eq(Nat, S(3), S(add(2,1))) combinedProof = let add21 = add (H.natLit 2) (H.succ H.zero); three = H.natLit 3; sadd21 = H.succ add21; sthree = H.succ three; # Step 1: cong succ congStep = H.j nat add21 (H.lam "y" nat (y: H.lam "_" (eq nat add21 y) (_: eq nat sadd21 (H.succ y)))) refl three refl; # Step 2: sym on the cong result in (checkHoas (eq nat sthree sadd21) (H.j nat sadd21 (H.lam "y" nat (y: H.lam "_" (eq nat sadd21 y) (_: eq nat y sadd21))) refl sthree congStep)).tag == "j"; ``` ## Verified extraction Proofs establish that properties hold. Verified extraction goes further: write an implementation in HOAS, the kernel type-checks it against a specification, and `v.verify` extracts a callable Nix function. The result is an ordinary Nix value — an integer, a boolean, a function, a list — but one whose implementation was machine-checked before use. ### The simplest case ```nix let H = fx.types.hoas; v = fx.types.verified; # Kernel-verified successor: Nat → Nat succFn = v.verify (H.forall "x" H.nat (_: H.nat)) (v.fn "x" H.nat (x: H.succ x)); in succFn 5 # → 6 ``` `v.verify` does three things: elaborates the HOAS into kernel terms, type-checks the implementation against the type, and extracts the result as a Nix value. The extracted function is plain Nix — no kernel overhead at call time. `v.fn` is a convenience wrapper around `H.lam` that threads the extraction metadata. You could write raw `H.lam` instead, but `v.fn` handles the plumbing for multi-argument functions and pattern matching. ### Pattern matching `v.match` builds a `NatElim` with a constant motive. You provide the result type, the scrutinee, and branches for `zero` and `succ`: ```nix # Verified addition: Nat → Nat → Nat addFn = v.verify (H.forall "m" H.nat (_: H.forall "n" H.nat (_: H.nat))) (v.fn "m" H.nat (m: v.fn "n" H.nat (n: v.match H.nat m { zero = n; succ = _k: ih: H.succ ih; }))); addFn 2 3 # → 5 addFn 0 7 # → 7 ``` The `succ` branch receives two arguments: `k` (the predecessor) and `ih` (the inductive hypothesis — the result for `k`). For addition, `ih` is `add(k, n)`, so wrapping it with `succ` gives `add(S(k), n)`. ### Boolean and cross-type elimination `v.if_` elaborates to `H.boolElim` on the derived `H.bool`: ```nix # Verified not: Bool → Bool notFn = v.verify (H.forall "b" H.bool (_: H.bool)) (v.fn "b" H.bool (b: v.if_ H.bool b { then_ = v.false_; else_ = v.true_; })); notFn true # → false notFn false # → true ``` Cross-type elimination — scrutinize one type, return another — works by specifying a different result type: ```nix # Verified isZero: Nat → Bool isZeroFn = v.verify (H.forall "n" H.nat (_: H.bool)) (v.fn "n" H.nat (n: v.match H.bool n { zero = v.true_; succ = _k: _ih: v.false_; })); isZeroFn 0 # → true isZeroFn 5 # → false ``` ### List operations `v.map`, `v.filter`, and `v.fold` are verified list combinators. Each takes HOAS terms, not Nix functions — the kernel verifies the entire pipeline: ```nix # Composed pipeline: filter zeros, then sum # Input: [0, 3, 0, 2, 1] → Filter: [3, 2, 1] → Sum: 6 composedResult = let input = H.cons H.nat (v.nat 0) (H.cons H.nat (v.nat 3) (H.cons H.nat (v.nat 0) (H.cons H.nat (v.nat 2) (H.cons H.nat (v.nat 1) (H.nil H.nat))))); nonZero = v.fn "n" H.nat (n: v.match H.bool n { zero = v.false_; succ = _k: _ih: v.true_; }); addCombine = v.fn "a" H.nat (a: v.fn "acc" H.nat (acc: v.match H.nat a { zero = acc; succ = _k: ih: H.succ ih; })); in v.verify H.nat (v.fold H.nat H.nat (v.nat 0) addCombine (v.filter H.nat nonZero input)); # → 6 ``` The kernel verifies the filter predicate (`Nat → Bool`), the fold combinator (`Nat → Nat → Nat`), and their composition before extracting the result. A type error in any component — say, returning a `Nat` where the filter expects a `Bool` — fails at `nix eval`, not at runtime. ### Records and string operations The kernel supports record types (elaborated as nested Sigma) and string equality (`strEq` is a kernel primitive). Together they handle verified functions over structured data with string fields: ```nix let H = fx.types.hoas; v = fx.types.verified; RecTy = H.record [ { name = "name"; type = H.string; } { name = "target"; type = H.string; } ]; # Verified: does the name match the target? matchFn = v.verify (H.forall "r" RecTy (_: H.bool)) (v.fn "r" RecTy (r: v.strEq (v.field RecTy "name" r) (v.field RecTy "target" r))); in { yes = matchFn { name = "hello"; target = "hello"; }; # → true no = matchFn { name = "hello"; target = "world"; }; # → false } ``` `v.field` desugars to the right chain of `fst`/`snd` projections for the field's position in the Sigma chain. `v.strEq` reduces in the kernel via the `StrEq` primitive — it compares string literals during normalization, producing `true` or `false` as kernel values. ## What the kernel can and cannot prove The nix-effects kernel implements Martin-Löf type theory with universes, dependent functions, dependent pairs, identity types, natural numbers, lists, sums, unit, an indexed-description family (`Desc I`, `μ`, `desc-ind`), and seven axiomatized Nix primitives (String, Int, Float, Attrs, Path, Function, Any). Booleans and Void are derived — `H.bool` as `μ ⊤ (plus (retI tt) (retI tt)) tt`, `H.void` as `Fin 0` — and come with derived eliminators (`H.boolElim` via `desc-ind`, `H.absurd` via a direct `J`-transport). The kernel can prove any property that reduces to a comparison of normal forms. **It can prove:** - Equalities between computed values: `add(3, 5) = 8`, `length([1,2,3]) = 3`, `append([1,2], [3]) = [1,2,3]` - Properties of concrete data: "this config field is in the allowed set," "this port number is valid," "these two strings match" - Generic combinators: `cong`, `sym`, `trans`, and `transport` type-check with abstract variables - Verified function extraction: any function expressible with the kernel's eliminators can be verified and extracted **It cannot prove:** - **Symbolic induction.** `forall n, n + 0 = n` requires induction over an abstract variable. The `NatElim` evaluator only reduces on concrete values (`VZero`, `VSucc`), not symbolic ones. You can prove `3 + 0 = 3` and `100 + 0 = 100`, but not the universal statement. The evaluator would need to produce symbolic normal forms, which is a fundamentally different normalization strategy. - **Properties of Nix builtins.** The kernel axiomatizes `String`, `Int`, `Float`, etc. as opaque types. `builtins.stringLength` is not a kernel function — the kernel has `strEq` for string comparison but no string operations beyond equality and list membership. - **Eta-expansion.** The kernel does not identify `f` with `λx.f(x)`. Functions that are extensionally equal but intensionally different are not convertible. - **User-defined recursive types.** The kernel has primitive inductives (Nat, List, Sum) with their own eliminators and an indexed-description family (`Desc I` / `μ` / `desc-ind`) that the macro layer uses to build derived inductives such as `Bool`, `Fin`, `Vec`, and `Eq`-as-description. Arbitrary user-defined inductive families (binary trees, red-black trees, etc.) require the description-macro layer; they are not written directly against the kernel. The macro layer exposes four user-facing entry points for defining inductive types. `H.datatype name cons` compiles a monomorphic, ⊤-indexed datatype from a list of `H.con name fields` specs (`H.field`, `H.fieldD`, `H.recField`, `H.piField`, `H.piFieldD` for the field shapes). `H.datatypeP name params mkCons` adds a parameter layer, threading each parameter through an outer Π binder. `H.datatypeI name I consList` adds an arbitrary index type `I : U`; constructors use `H.conI name fields targetIdx` to specify their target index as a function of earlier field markers, and recursive fields at non-default indices use `H.recFieldAt name idxFn` (plain `H.recField` is rejected at `I ≠ ⊤`). `H.datatypePI name params indexFn mkCons` combines parameters and indexing — the index type itself may depend on parameters, which is what `Eq A a : A → U` requires. Each macro returns a record exposing `.D : Desc I`, `.T : Π(i:I). U` (or `μ ⊤ D tt` at the ⊤-sugar path), per-constructor fields, and `.elim` built on `desc-ind`. The prelude's `FinDT`, `VecDT`, and `EqDT` are the canonical indexed instances and drive the surface `H.fin` / `H.vec` / `H.eqDT` bindings as thin forwarders. For Nix, the "concrete data" restriction is less of a limitation than it sounds. Nix evaluates configurations completely before building — every module option, every service config, every package attribute is a concrete value at eval time. The kernel verifies all computable properties of that concrete data. What it gives up is proving things about *all possible* configurations generically. In practice, you prove properties of the specific configuration being built, which is the one that matters. ## Quick reference | Pattern | Type | Proof term | |---------|------|------------| | Computational equality | `Eq(A, x, y)` where `x`, `y` normalize to same value | `Refl` | | Dependent witness | `Σ(x:A). P(x)` | `(value, proof)` | | Case analysis (bool, derived) | `H.boolElim k motive true_case false_case b` | Result of elimination | | Structural recursion (nat) | `NatElim(motive, base, step, n)` | Result of elimination | | List recursion | `ListElim(elem, motive, nil_case, cons_case, xs)` | Result of elimination | | Sum dispatch | `SumElim(L, R, motive, left_case, right_case, s)` | Result of elimination | | Congruence | `Eq(A,x,y) → Eq(B, f(x), f(y))` | `J(A, x, λy'.λ_. Eq(B,f(x),f(y')), Refl, y, p)` | | Symmetry | `Eq(A,x,y) → Eq(A,y,x)` | `J(A, x, λy'.λ_. Eq(A,y',x), Refl, y, p)` | | Transitivity | `Eq(A,x,y) → Eq(A,y,z) → Eq(A,x,z)` | `J(A, y, λz'.λ_. Eq(A,x,z'), p, z, q)` | | Transport | `Eq(A,x,y) → P(x) → P(y)` | `J(A, x, λy'.λ_. P(y'), px, y, p)` | | Ex falso (derived `H.void = Fin 0`) | `H.void → A` | `H.absurd A x` (routes through `absurdFin0`) | | Verified function | `v.verify type impl` | Extracted Nix function | ## References 1. Martin-Löf, P. (1984). *Intuitionistic Type Theory*. Bibliopolis. 2. The Univalent Foundations Program (2013). *Homotopy Type Theory: Univalent Foundations of Mathematics*. Institute for Advanced Study. [[pdf](https://homotopytypetheory.org/book/)] 3. Norell, U. (2007). *Towards a practical programming language based on dependent type theory*. PhD thesis, Chalmers. [[pdf](https://www.cse.chalmers.se/~ulfn/papers/thesis.pdf)] ### Theory Nine papers shaped nix-effects. Here's how each one maps to code. ## Algebraic effects and the freer monad A computation is a tree of effects with continuations. A handler walks the tree, interpreting each effect — either resuming the continuation with a value or aborting it. That's the handler model from Plotkin & Pretnar (2009), and nix-effects implements it directly. A computation is either: - `Pure value` — finished, returning a value - `Impure effect continuation` — suspended, waiting for a handler to service `effect` and feed the result to `continuation` `send` creates an `Impure` node: ```nix send "get" null # Impure { effect = { name = "get"; param = null; }; queue = [k]; } ``` `bind` appends to the continuation queue: ```nix bind (send "get" null) (s: pure (s * 2)) # Impure { effect = get; queue = [k1, k2] } — O(1) per bind ``` Handlers provide the interpretation: ```nix handlers = { get = { param, state }: { resume = state; inherit state; }; put = { param, state }: { resume = null; state = param; }; }; ``` `resume` feeds a value to the continuation. `abort` discards it and halts. ## FTCQueue: O(1) bind Naïve free monads have O(n²) bind chains. The problem is reassociation: ``` (m >>= f) >>= g ≡ m >>= (f >=> g) ``` Each reassociation traverses the whole tree. Kiselyov & Ishii (2015) solved this by storing continuations in a catenable queue (FTCQueue) instead of a list. `snoc` is O(1); queue application (`qApp`) amortizes the reassociation across traversal. Total cost: O(n) for n bind operations, regardless of nesting depth. This matters in practice — a `DepRecord` with 100 fields sends 100 effects, each of which binds. Without the queue, validation time would be quadratic in the number of fields. The interpreter that processes these queued continuations uses defunctionalization (Reynolds 1972): the recursive handler becomes a data structure — effect name, parameter, handler result — and a worklist loop (`builtins.genericClosure`) iterates over steps instead of recursing. This is the pattern Van Horn & Might (2010) identified in *Abstracting Abstract Machines*: store-allocated continuations plus worklist iteration give you bounded stack depth. The [Trampoline](/nix-effects/guide/trampoline) chapter covers the implementation — how `genericClosure` becomes a trampoline, why `deepSeq` prevents thunk accumulation, and what the 1,000,000-operation benchmark actually measures. ## Value-dependent types Martin-Löf (1984) is where types that depend on values come from. In nix-effects, all types bottom out in the MLTT kernel (`src/tc/`), which handles type checking, universe level computation, and proof verification. The user-facing API provides convenience constructors on top. **Sigma (Σ)** — the dependent pair. The second component's type is a function of the first component's value: ```nix Σ(fipsMode : Bool). if fipsMode then ListOf FIPSCipher else ListOf String ``` In nix-effects: ```nix Sigma { fst = Bool; snd = b: if b then ListOf FIPSCipher else ListOf String; } ``` `Sigma.validate` decomposes the check: validate `fst` first, then — only if it passes — evaluate `snd fst-value` and validate that. The dependent expression is never evaluated on a wrong-typed input. That ordering is the whole point. **Pi (Π)** — dependent function type. The return type depends on the argument: ```nix Pi { domain = String; codomain = _: Int; } ``` The kernel's decision procedure checks `isFunction` — closures are opaque, so that's all it can verify at introduction. Full verification happens at elimination via the kernel's type-checking judgment. **Universe hierarchy.** Types themselves have types, stratified from `Type_0` through `Type_4` to guard against Russell's paradox: ```nix (typeAt 0).check Int # true — Int lives at universe 0 level String # 0 (typeAt 1).check (typeAt 0) # true — Type_0 lives at universe 1 ``` Universe levels are computed by the kernel's `checkTypeLevel`: `level(Pi(A,B)) = max(level(A), level(B))`, `level(U(i)) = i+1`. Self-containing universes (`U(i) : U(i)`) are rejected — `level(U(i)) = i+1 > i`, so the check fails. This prevents both accidental and adversarial paradoxes for every kernel-backed type. ## Refinement types Sometimes you need a type that's narrower than `Int` but wider than an enum. Freeman & Pfenning (1991) introduced the refinement type: given a base type T and a predicate P, the type {x:T | P(x)} admits only values of T that satisfy P. `refined` is the direct implementation — `refined "Port" Int (x: x >= 1 && x <= 65535)` is {x:Int | 1 ≤ x ≤ 65535} with a name attached. Rondon et al. (2008) later scaled the idea with SMT-based inference under the name *Liquid Types*. We skip the solver and use runtime predicate checking: ```nix Port = refined "Port" Int (x: x >= 1 && x <= 65535); NonEmpty = refined "NonEmpty" String (s: builtins.stringLength s > 0); Nat = refined "Nat" Int (x: x >= 0); ``` `Port.check` composes the kernel's decision (`Int`) with the refinement predicate. Combinators for building compound predicates: ```nix allOf [ pred1 pred2 ] # conjunction anyOf [ pred1 pred2 ] # disjunction negate pred # negation ``` ## Soundness and what Nix provides The kernel's soundness is standard MLTT metatheory. Martin-Löf (1984) set out the rules, and the Mini-TT lineage (Coquand et al. 2009, Kovács 2022, and the elaboration-zoo and pi-forall tutorials) gives the bidirectional elaboration and normalization-by-evaluation recipe the kernel follows. Nothing in the kernel is novel on the metatheory side, and none of the soundness argument routes through anything Nix-specific. What Nix contributes is a faithful runtime for definitional equality. NbE requires that reducing open terms is deterministic and side effect free, and pure Nix evaluation gives you exactly that. `builtins.trace` and `builtins.throw` are not observable through definitional equality, so they cannot perturb conversion checking. The effect layer sits above the kernel as meta-level freer monad data. `Impure` and `Pure` attrsets are values walked by pure handlers. They are not object-language effects in the kernel's grammar, and the kernel has no constructor for them. This is how the effect layer can surface kernel errors as `typeCheck` effects carrying context paths without widening the trusted core. ## Graded linear types Orchard, Liepelt & Eades (2019) introduced a type system where each variable carries a usage grade from a resource semiring. We implement three points on that spectrum: `Linear` (exactly one use), `Affine` (at most one), and `Graded` (exactly n uses). In practice, the handler maintains a resource map counting each `consume` call against a `maxUses` bound. At handler exit, a finalizer checks that every resource was consumed the expected number of times. The grade discipline is enforced at runtime through the effect system, not statically — so you get usage tracking without a custom type checker, but violations show up at eval time rather than before it. That's a real trade-off, and for configuration validation we're comfortable with it. ## Higher-order contracts and blame Findler & Felleisen (2002) solved a problem that shows up immediately when you try to check function types: you can't test a function contract at the point of definition. A function is a closure — opaque. The only way to check it is to wrap it and verify at application boundaries. In nix-effects, this is exactly what happens. `decide(H.forall ..., f)` can only confirm `builtins.isFunction f` — the kernel can't look inside a Nix closure. For full verification, you write the implementation in HOAS, the kernel type-checks the term, and `extract` wraps the result as a Nix function that elaborates its arguments at every call boundary. The contract is enforced at application, not definition. That's Findler & Felleisen. Their other contribution is blame tracking. When a check fails, the error needs to say *which* contract was violated and *where*. In nix-effects, `.validate` sends `typeCheck` effects carrying blame context — type name, field path, rejected value — and the handler decides the error policy: `strict` throws immediately, `collecting` accumulates all failures, `logging` records every check. Same kernel judgment, different reporting strategy — the handler pattern (Plotkin & Pretnar) composes with the contract pattern (Findler & Felleisen) to separate what to check from how to report. ## References 1. Plotkin, G., & Pretnar, M. (2009). *Handlers of Algebraic Effects*. ESOP 2009. [[doi](https://doi.org/10.1007/978-3-642-00590-9_7)] 2. Kiselyov, O., & Ishii, H. (2015). *Freer Monads, More Extensible Effects*. Haskell Symposium 2015. [[pdf](https://okmij.org/ftp/Haskell/extensible/more.pdf)] 3. Martin-Löf, P. (1984). *Intuitionistic Type Theory*. Bibliopolis. 4. Rondon, P., Kawaguchi, M., & Jhala, R. (2008). *Liquid Types*. PLDI 2008. [[doi](https://doi.org/10.1145/1375581.1375602)] 5. Findler, R., & Felleisen, M. (2002). *Contracts for Higher-Order Functions*. ICFP 2002. [[doi](https://doi.org/10.1145/581478.581484)] 6. Van Horn, D., & Might, M. (2010). *Abstracting Abstract Machines*. ICFP 2010. (See [Trampoline](/nix-effects/guide/trampoline)) 7. Freeman, T., & Pfenning, F. (1991). *Refinement Types for ML*. PLDI 1991. [[doi](https://doi.org/10.1145/113445.113468)] 8. Orchard, D., Liepelt, V., & Eades, H. (2019). *Quantitative Program Reasoning with Graded Modal Types*. ICFP 2019. [[doi](https://doi.org/10.1145/3341714)] ## Prior art - Borja, V. (2026). *nfx: Nix Algebraic Effects System with Handlers*. [[github](https://github.com/vic/nfx)] — Implements algebraic effects in pure Nix using a context-passing model with `immediate`/`pending` constructors. nix-effects adopted nfx's `adapt` handler combinator, `mk { doc, value, tests }` API pattern, and effect module vocabulary (`state`, `acc`, `conditions`, `choice`, streams), while building a new core on the freer monad encoding from Kiselyov & Ishii (2015) and adding value-dependent types and a type-checking kernel that nfx does not attempt. ### Trampoline The trampoline is how nix-effects interprets freer monad computations with O(1) stack depth in a language with no iteration primitives and no tail-call optimization. ## The problem Nix is a pure, lazy, functional language. It has no loops. Every "iteration" is recursion. A naïve free monad interpreter using mutual recursion would build a call stack proportional to the computation length: ``` run (bind (bind (bind ... (send "get" null) ...) ...) ...) → run step1 → run step2 → run step3 → ... (N frames deep) ``` For validation of a large config — say, a NixOS module with hundreds of fields — this would blow the stack. ## The solution: `builtins.genericClosure` Nix's `builtins.genericClosure` is the only built-in iterative primitive. It implements a worklist algorithm: ``` genericClosure { startSet = [ initialNode ]; operator = node -> [ ...nextNodes ]; } ``` `operator` is called on each node. New nodes returned by `operator` are added to the worklist if their `key` hasn't been seen before. The result is the set of all reachable nodes. nix-effects repurposes this as a trampoline: each step of computation is a node. The `operator` function handles one effect and produces the next step as a singleton list. The computation terminates when `operator` returns `[]` (i.e., when we reach a `Pure` node). ```nix steps = builtins.genericClosure { startSet = [{ key = 0; _comp = comp; _state = initialState; }]; operator = step: if isPure step._comp then [] # halt else [ nextStep ]; # one more step }; ``` Stack depth: **O(1)**. `genericClosure` handles its own iteration internally; the `operator` function is never deeply nested. ## The thunk problem and `deepSeq` `genericClosure` only forces the `key` field of each node (for deduplication). All other fields — including `_state` and `_comp` — are lazy thunks. Without intervention, after N steps the `_state` field would be: ``` f(f(f(... f(initialState) ...))) # N thunks deep ``` Forcing the final `_state` would then rebuild the entire call stack in thunk evaluation, defeating the purpose. The fix: make `key` depend on `builtins.deepSeq newState`: ```nix key = builtins.deepSeq newState (step.key + 1) ``` Since `genericClosure` forces `key`, it also forces `deepSeq newState`, which eagerly evaluates the state at each step. No thunk chain builds up. Test suite validates 100,000 operations; manual runs confirm 1,000,000 operations in ~3 seconds with constant memory. ## Defunctionalization The interpreter defunctionalizes (**Reynolds 1972**) the recursive handler: the continuation moves from the call stack into an explicit data structure (the FTCQueue). The worklist loop processes these continuations iteratively rather than recursively — the same pattern identified by **Van Horn & Might (2010)** in *Abstracting Abstract Machines*. **Gibbons (2022)** *Continuation-Passing Style, Defunctionalization, Accumulations, and Associativity* shows the hidden precondition: this transformation is valid when the accumulated operation is associative. For nix-effects, the handler state transformations compose associatively because function composition is associative. ## References - Reynolds, J. C. (1972). *Definitional Interpreters for Higher-Order Programming Languages*. ACM Annual Conference. - Van Horn, D., & Might, M. (2010). *Abstracting Abstract Machines*. ICFP 2010. - Gibbons, J. (2022). *Continuation-Passing Style, Defunctionalization, Accumulations, and Associativity*. The Art, Science, and Engineering of Programming, 6(2). [[doi](https://doi.org/10.22152/programming-journal.org/2022/6/7)] - Kiselyov, O., & Ishii, H. (2015). *Freer Monads, More Extensible Effects*. ### Systems Architecture nix-effects is a freer-monad effect layer with a dependent type checker on top, all running at `nix eval` time. The effect layer is the foundation — handlers, blame tracking, and error policy all flow through it. The type checker is a Lean-light MLTT core that uses the effect infrastructure for its own checking pipeline. The type-checking kernel lives in `src/tc/`. Every `fx.types` type carries a `_kernel` field — an HOAS tree that elaborates to a kernel type — and `.check` is derived from `decide(_kernel, v)`. Universe levels are computed by `checkTypeLevel`. Refinement types layer a guard predicate on top of the kernel's structural check (`check = kernelDecide(v) ∧ guard(v)`). ## Foundation layers nix-effects has two foundation layers: **The effects kernel.** Freer monad with FTCQueue for O(1) bind. `builtins.genericClosure` trampoline for O(1) stack depth. Handler-swap pattern for configurable interpretation. This layer is solid — tested at 1,000,000 operations, constant memory, ~3 seconds. **The type-checking kernel.** Every type is defined by its kernel representation — an HOAS type tree that the MLTT kernel can check. `.check` is derived mechanically from the kernel's `decide` procedure. `.validate` sends `typeCheck` effects through the freer monad for blame tracking. You choose the error policy by choosing the handler. For first-order types, the kernel's decision procedure is the full check. But it has an inherent limitation for higher-order types: `Pi.check` can only verify `isFunction` — it can't verify that a function maps every A-value to a B-value. Decision procedures are decidable and total, which makes them practical, but they can only state properties about the values in front of them. "For ALL services in this configuration, if they listen on a port, a firewall rule exists" — that's a universally quantified statement. No decision procedure can check it. You need structural verification of a proof term, not evaluation of a predicate. ## Kernel integration ``` Type system API (src/types/) Record, ListOf, DepRecord, refined, Pi, Sigma, ... | | elaboration (src/tc/elaborate/, src/tc/hoas/) v Type-checking kernel (MLTT, src/tc/) | | typeError sent as effect request v Effects kernel (freer monad + FTCQueue, src/kernel.nix) | | handler (strict / collecting / ...) interprets effects v Pure Nix ``` Types in `fx.types.*` compile to kernel HOAS trees via `elaborate.nix`. `Record`, `ListOf`, `DepRecord`, and `refined` all resolve to Σ/Π/μ constructions the kernel can check. `.check` runs `decide(_kernel, v)`; `.validate v` wraps the same pipeline in a `typeCheck` effect request so handlers in `src/effects/typecheck.nix` can attach blame context. `.prove` runs bidirectional kernel checking on a HOAS proof term. All three go through the same judgment `Γ ⊢ t : T`. ## The trusted kernel The kernel is small and auditable. It implements a core dependent type theory — something in the neighborhood of MLTT with natural numbers, identity types, and a cumulative universe hierarchy. ### Core judgments The kernel checks four judgments: ``` ctx ⊢ term : type (type checking) ctx ⊢ term ⇒ type (type inference) type_a ≡ type_b (definitional equality, via normalization) ⊢ ctx ok (context well-formedness) ``` ### The term language Terms are Nix attrsets. Each has a `tag` field for the constructor: ```nix # Core constructors (see kernel-spec.md §2 for the full grammar) { tag = "var"; idx = 0; } # de Bruijn index { tag = "pi"; name = "x"; domain = ...; codomain = ...; } # Π type { tag = "lam"; name = "x"; domain = ...; body = ...; } # λ abstraction { tag = "app"; fn = ...; arg = ...; } # application { tag = "sigma"; name = "x"; fst = ...; snd = ...; } # Σ type { tag = "pair"; fst = ...; snd = ...; } # pair { tag = "fst"; pair = ...; } # first projection { tag = "snd"; pair = ...; } # second projection { tag = "nat"; } # ℕ { tag = "zero"; } # 0 { tag = "succ"; pred = ...; } # S(n) { tag = "list"; elem = ...; } # List A { tag = "nil"; elem = ...; } { tag = "cons"; elem = ...; head = ...; tail = ...; } { tag = "unit"; } { tag = "tt"; } # ⊤ { tag = "sum"; left = ...; right = ...; } # A + B { tag = "inl"; left = ...; right = ...; term = ...; } { tag = "inr"; left = ...; right = ...; term = ...; } { tag = "eq"; type = ...; lhs = ...; rhs = ...; } # Id type { tag = "refl"; } # reflexivity { tag = "j"; type = ...; lhs = ...; motive = ...; base = ...; rhs = ...; eq = ...; } { tag = "U"; level = 0; } # universe { tag = "ann"; term = ...; type = ...; } # annotation { tag = "let"; name = "x"; type = ...; val = ...; body = ...; } # let # Eliminators: nat-elim, list-elim, sum-elim, desc-ind # Description universe: desc, desc-ret, desc-arg, desc-rec, desc-pi, desc-plus # Indexed fixpoint: mu, desc-con ``` We use de Bruijn indices internally. The surface language uses names (see "Making the syntax livable" below). A small elaborator translates named terms to de Bruijn core terms. ### The core operations (Normalization by Evaluation) The kernel uses **NbE** (Normalization by Evaluation) rather than explicit substitution. Terms (Tm) are interpreted into a semantic domain (Val) by `eval`, and read back to normal forms by `quote`. This avoids the quadratic cost of explicit substitution. **Evaluation** (`eval : Env × Tm → Val`). Interprets a term in an environment of values, performing beta and iota reductions eagerly. Closures `(env, body)` capture the environment, avoiding substitution. Trampolined via `genericClosure` for recursive eliminators (NatElim, ListElim) to guarantee O(1) stack depth. **Quotation** (`quote : ℕ × Val → Tm`). Converts a value back to a term, translating de Bruijn levels to indices. Trampolined for deep VSucc/VCons chains. **Conversion** (`conv : ℕ × Val × Val → Bool`). Checks definitional equality of two values. Purely structural on normalized values — no type information used. No eta expansion. Trampolined for deep VSucc and VCons chains. **Bidirectional type checking** (`check`/`infer`). Inference mode synthesizes a type from a term; checking mode verifies a term against an expected type. Switching between modes happens at annotations and eliminators. The algorithm follows Dunfield & Krishnaswami (2021). Type errors are reported via effects; the handler determines policy. ### Why the trampoline is essential Normalization of proof terms is iterative. A proof by induction on a natural number n unfolds n reduction steps. A naive recursive normalizer blows the stack for large proofs. The `builtins.genericClosure` trampoline that nix-effects already uses for effect interpretation handles this identically: ```nix normalize = term: let steps = builtins.genericClosure { startSet = [{ key = 0; _term = term; }]; operator = step: let next = whnfStep step._term; in if next.done then [] else [{ key = builtins.deepSeq next.term (step.key + 1); _term = next.term; }]; }; in (lib.last steps)._term; ``` O(1) stack depth. `deepSeq` breaks thunk chains. The same technique that lets nix-effects run 1,000,000 effect operations lets the kernel normalize complex proof terms without hitting Nix's stack limit. ### Why the FTCQueue matters During type checking, the checker processes a sequence of obligations: check this argument, then check that body, then verify this equality. These are continuations — "after you finish checking A, check B with the result." The FTCQueue (catenable queue) from Kiselyov & Ishii gives O(1) continuation chaining. Without it, a deeply nested proof term with 1000 nested applications would produce O(n^2) overhead from left-nested bind chains in the checker's own computation. With it: O(n) total. ## The checker as an effectful computation The checker itself is a nix-effects computation. Its operations are effects: ```nix # Core effects of the type-checking kernel check = ctx: term: type: send "check" { inherit ctx term type; }; infer = ctx: term: send "infer" { inherit ctx term; }; unify = a: b: send "unify" { inherit a b; }; freshLevel = send "freshLevel" null; typeError = msg: send "typeError" msg; ``` The handler determines checking behavior: ```nix # Strict: abort on first error strictChecker = { typeError = { param, state }: { abort = null; state = state ++ [param]; }; ... }; # Collecting: gather all errors collectingChecker = { typeError = { param, state }: { resume = null; state = state ++ [param]; }; ... }; # Interactive: yield on error for tactic guidance interactiveChecker = { typeError = { param, state }: { resume = null; state = state // { paused = param; }; }; ... }; ``` Same handler-swap pattern that the current `ServiceConfig.validate` uses. Same trampoline running the computation. The kernel is just another effectful program running on the effects infrastructure. ## Types grounded in the kernel This is where the kernel-first approach differs from adding a proof checker alongside ad hoc contracts. Every type in the public API compiles to a kernel construction. A type IS its kernel representation, and all operations are derived from it. ### Elaboration: Nix values to kernel terms When you check a value against a type, elaboration translates the Nix value into a kernel term, and the kernel checks it: ```nix # Nat.check 42 # Elaboration: 42 → succ^42(zero) # Kernel: ⊢ succ(succ(...(zero)...)) : Nat ✓ # (ListOf Nat).check [1, 2, 3] # Elaboration: [1,2,3] → cons(succ(zero), cons(succ(succ(zero)), cons(succ(succ(succ(zero))), nil))) # Kernel: ⊢ cons(1, cons(2, cons(3, nil))) : List Nat ✓ ``` ### Decidable fast paths Elaborating `42` to `succ^42(zero)` and checking structurally is correct but expensive. For decidable properties — which is everything the decision procedure handles — we derive a fast path from the kernel type definition. The kernel defines `Nat` as an inductive type with `zero` and `succ`. From that definition, a decision procedure is mechanically derived: ```nix # Decision procedure derived from kernel Nat definition Nat.check = v: builtins.isInt v && v >= 0; ``` This is the same predicate the surface API exposes. It's justified by the kernel, not ad hoc. You prove once (by structural induction on the derivation rules) that the decision procedure agrees with the kernel type. Then you use the fast predicate at eval time and fall back to the kernel for properties the predicate can't express. This is how Lean handles `Decidable` instances. For decidable propositions, evaluation IS proof. The decision procedure is the computational content of the decidability proof, extracted as a function. ### How current types map to kernel types | Current API | Kernel construction | Fast path | |------------|-------------------|-----------| | `Nat` | Inductive type (zero, succ) | `isInt v && v >= 0` | | `String` | Primitive (axiom) | `builtins.isString v` | | `ListOf A` | Inductive type (nil, cons A) | `builtins.isList v && all A.check v` | | `Record { a = A; b = B }` | Sigma (a : A) (b : B) | Field-wise guard | | `DepRecord [...]` | Nested Sigma | Dependent field-wise guard | | `Sigma { fst, snd }` | Kernel Sigma directly | `fst.check v.fst && (snd v.fst).check v.snd` | | `Pi { domain, codomain }` | Kernel Pi directly | `isFunction` (guard only) | | `refined "P" A pred` | Subset type `{ x : A \| P(x) }` | `A.check v && pred v` | | `Either A B` | Sum type (inl, inr) | Tag-based dispatch | | `typeAt n` | `Type n` (universe) | `v ? universe && v.universe <= n` | For first-order types (Nat, String, ListOf, Record), the fast path IS the full check — these are decidable. The kernel adds nothing at runtime for individual values; it adds the ability to state and verify universal properties about families of values. For higher-order types (Pi), the fast path can only check the introduction form (`isFunction`). The kernel adds full verification at elimination: `⊢ f(a) : B(a)` for specific `a`, or `⊢ p : Pi A B` for a proof term witnessing the universal property. ### Blame tracking as an effect Elaboration-mode type checking can send blame effects just as the current `validate` does — the kernel judgment emits `typeCheck` effects with context paths (`List[Nat][3]`) for error reporting. The handler determines whether to abort, collect, or log. Same pattern, now backed by a kernel judgment rather than an ad hoc predicate. ```nix # Effectful checking with blame: elaboration sends kernel judgments as effects checkWithBlame = type: value: context: let judgment = elaborate type value; in bind (send "typeCheck" { inherit type value context; }) (_: kernelCheck judgment); ``` ## Infinite universes via streams The current hardcoded `Type_0` through `Type_4` becomes a lazy stream: ```nix universes = stream.iterate (u: { level = u.level + 1; type = typeAt (u.level + 1); }) { level = 0; type = typeAt 0; }; ``` The stream unfolds on demand. If your types max out at level 3, level 4 is never computed. The trampoline handles the stream iteration. ### Universe level inference The kernel computes levels by structural recursion on types: ``` level(Nat) = 0 level(Pi A B) = max(level(A), level(B)) level(Sigma A B) = max(level(A), level(B)) level(Type n) = n + 1 ``` No manual annotations. The kernel infers levels and verifies stratification. The current `universe` field — a trusted declaration that nothing enforces — becomes a computed, verified property. ### Universe polymorphism as an effect Level allocation is an algebraic effect: ```nix # A universe-polymorphic definition requests a level polyList = bind freshLevel (u: pure (pi (typeAt u) (A: typeAt u))); # Different handlers instantiate differently atLevel3 = fx.run polyList (fixedLevel 3) null; # Or: all instantiations as a stream allLevels = stream.map (u: fx.run polyList (fixedLevel u) null ) (stream.iterate (n: n + 1) 0); ``` The definition doesn't commit to a level. The handler decides. ### Constraint solving via genericClosure Level constraints (`?u >= max(?v, ?w)`) accumulate during checking. The solver iterates to a fixed point: ```nix solveLevels = constraints: let steps = builtins.genericClosure { startSet = [{ key = 0; solved = {}; changed = true; }]; operator = state: if !state.changed then [] else let next = propagate state.solved constraints; in [{ key = builtins.deepSeq next.solved (state.key + 1); inherit (next) solved changed; }]; }; in (lib.last steps).solved; ``` Same trampoline. Same `deepSeq` trick. The universe solver reuses the exact infrastructure that runs effect handlers. ## Making the syntax livable Writing proof terms as raw attrsets is not viable. Four techniques, from least to most ambitious: ### HOAS: Nix lambdas as binders Higher-Order Abstract Syntax uses Nix's own functions for variable binding. The combinator applies a Nix lambda to a fresh variable attrset, getting scope and shadowing for free: ```nix let inherit (proof) forall lam nat zero succ eq refl cong ind; in # Proposition: forall n : Nat, n + 0 = n prop = forall nat (n: eq nat (plus n zero) n); # Proof: induction on n pf = ind nat (k: eq nat (plus k zero) k) # motive refl # base: 0 + 0 = 0 (k: ih: cong succ ih) # step: cong S on IH ; ``` The combinator `forall nat (n: ...)` calls the Nix function with a fresh `{ tag = "var"; ... }` and builds the `pi` AST node. Variable names, scope, and alpha-equivalence are handled by Nix's own evaluator. ### Tagless final: construction IS checking Combinators type-check during construction. If the expression evaluates without error, the proof is valid: ```nix let # lam checks the body type against the codomain during construction lam = domain: bodyFn: let v = mkTypedVar domain; body = bodyFn v; in { term = mkLam domain body.term; type = mkPi domain body.type; }; # app checks function/argument type compatibility during construction app = fn: arg: let _ = assertTypeEq fn.type.domain arg.type; in { term = mkApp fn.term arg.term; type = subst fn.type.codomain arg.term; }; in ... ``` Error messages point to the combinator call that failed, not to a position in a flat AST. Nix's eval trace tells you which `lam` or `app` had the wrong types. ### Builder pattern: method chaining for readability Wrap terms in attrsets with methods for infix-like notation: ```nix let E = expr: type: { inherit expr type; plus = other: E (mkApp plusFn (mkPair expr other.expr)) nat; eq = other: E (mkEq nat expr other.expr) (typeAt 0); ap = arg: E (mkApp expr arg.expr) (subst type.codomain arg.expr); }; n = E (var 0) nat; z = E zero nat; in (n.plus z).eq n # reads as: n + 0 = n ``` ## Why the kernel cannot reason about its own types Girard (1972) showed that a type theory with `Type : Type` is inconsistent, and Hurkens (1995) gave a compact MLTT rendering of the same paradox. The standard fix is a strict universe hierarchy, and the kernel enforces it by computing `level(U(i)) = i + 1` from the typing derivation rather than trusting a declared level. Concretely, the kernel at universe level N reasons about computations at levels 0 through N-1, and its own checker effects live at level N. A proof term that tried to reference the kernel's own universe would force the level solver into a cycle where `?u` depends on `?u`, which the constraint solver detects and rejects. Universe stratification is therefore a computed, verified property rather than a trusted declaration. The kernel infers levels, the solver checks them, and Girard's paradox cannot be constructed because no term can be placed at a level that contains itself. ## The infrastructure reuse Every piece of the kernel is built on machinery nix-effects already provides: | Component | Built on | |-----------|---------| | Normalization loop | `builtins.genericClosure` trampoline | | Thunk prevention | `builtins.deepSeq` in worklist key | | Continuation chaining | FTCQueue (O(1) bind) | | Checker effects | Freer monad (`send`, `bind`, `pure`) | | Error policy | Handler swap (strict / collecting / interactive) | | Universe tower | `stream.iterate` | | Level constraint solving | `genericClosure` as fixed-point | | Surface syntax parsing | `builtins.match` + `genericClosure` Pratt parser | | Blame tracking | `typeCheck` effect with context paths | | Elaboration | Nix attrset → kernel term translation | The kernel doesn't require new infrastructure. It reuses the trampoline, the queue, the monad, the handlers, and the streams. nix-effects was built to do effectful computation in a language that has no effects. A type checker is effectful computation. ## Current architecture ### Effects substrate The effects kernel — `pure`, `impure`, `send`, `bind`, `run`, `handle`, `adapt`, FTCQueue, trampoline, all effect modules (state, error, reader, writer, acc, choice, conditions, linear), streams — is the substrate the type-checking kernel runs on. ### Type system grounding The type system layer in `src/types/` is grounded in the kernel: - `foundation.nix` — `mkType` with `kernelType` + optional refinement `guard` - `primitives.nix` — `String`, `Int`, `Bool`, etc. wrapping `builtins.is*` - `constructors.nix` — `Record`, `ListOf`, `Maybe`, `Either`, `Variant` - `dependent.nix` — `Pi`, `Sigma`, `Certified`, `Vector`, `DepRecord` - `refinement.nix` — `refined`, predicate combinators - `universe.nix` — `typeAt`, `Type_0` through `Type_4`, `level` All types have kernel backing via `kernelType`. The architecture is: 1. **Kernel module** (`src/tc/`, ~2200 lines) — term/value representations, NbE evaluator, quotation, conversion, bidirectional checking. Uses environments and closures, not explicit substitution. 2. **Elaboration module** (`src/tc/elaborate.nix`, `hoas.nix`) — translates the surface API into kernel types and translates Nix values into kernel terms. HOAS combinators for readable proof terms. 3. **Extraction layer** (`extract` in `elaborate.nix`) — reverses elaboration: kernel values back to Nix values. Enables verified functions: write in HOAS, kernel-check, extract usable Nix code. 4. **Convenience combinators** (`src/tc/verified.nix`) — higher-level interface for writing verified implementations with automatic motive construction and annotation insertion. 5. **Decision procedures** — `.check` on every type is the kernel's `decide` procedure: elaborate the value to HOAS, run bidirectional kernel checking, return a boolean. Refinement types extend this with a guard predicate conjoined at the leaf. 6. **Surface API** — the public-facing `fx.types.*` attrset. Same names, same usage patterns. `Record`, `ListOf`, `DepRecord`, `refined`, `Pi`, `Sigma` all work. `T.check v` runs the kernel's decide procedure. `T.prove prop` checks a proof term. ### What the API looks like ```nix # Checking a value (decide via kernel) fx.types.Nat.check 42 # true fx.types.(ListOf Nat).check [1, 2, 3] # true # Effectful validation with blame fx.run (fx.types.Nat.validate 42) handlers [] # Proving a universal property fx.types.prove ( forall nat (n: eq nat (plus n zero) n) ) proofTerm # { ok = true; } or type error # Verified function extraction v.verify (H.forall "x" H.nat (_: H.nat)) (v.fn "x" H.nat (x: H.succ x)) # → Nix function, correct by construction ``` ## What exists 1. **Type-checking kernel** (`src/tc/eval.nix`, `check.nix`, `quote.nix`, `conv.nix`, `term.nix`, `value.nix`). Pi, Sigma, Nat, List, Sum, Unit, identity types, indexed descriptions (`Desc I`, `μ`, `desc-ind`) with a first-class plus coproduct, cumulative universes, and 7 axiomatized primitive types (String, Int, Float, Attrs, Path, Function, Any). `Bool` and `Void` are derived: `H.bool = μ ⊤ (plus (retI tt) (retI tt)) tt`, `H.void = Fin 0`. Bidirectional checking with NbE. Fuel-bounded evaluation with `genericClosure` trampolining for stack safety. 2. **HOAS surface combinators** (`src/tc/hoas.nix`). `forall`, `lam`, `sigma`, `pair`, `natLit`, `natElim`, `boolElim`, `listElim`, `sumElim`, `j`, `refl`, `eq`, and more. Variable binding via Nix lambdas. Automatic elaboration from HOAS to de Bruijn core terms. 3. **Elaboration and extraction** (`src/tc/elaborate.nix`). Maps all type values to kernel terms via `elaborateValue`. `decide` function provides `.check` for all types. `extract` reverses elaboration, converting kernel values back to Nix values. Sentinel-based constant family detection for non-dependent Sigma/Pi. 4. **Kernel-grounded foundation** (`src/types/foundation.nix`). `mkType` requires `kernelType` and derives `.check` from `decide`. No hand-written predicates. All types — primitives, constructors, dependent types — have kernel backing. 5. **Convenience combinators** (`src/tc/verified.nix`). Higher-level interface: `v.verify type impl` writes in HOAS, kernel-checks, and extracts a usable Nix function. Includes `if_`, `match`, `matchList`, `matchSum`, `map`, `fold`, `filter`. ## Future work The following features remain unimplemented: - **Universe polymorphism as an effect.** Level allocation via `freshLevel` effect with handler-determined instantiation. - **Constraint solving via genericClosure.** Level constraint propagation to a fixed point for universe inference. - **Tagless final construction.** Type-checking during combinator construction. - **Builder pattern notation.** Method chaining for readable proof terms. ## References - Dunfield, J., & Krishnaswami, N. (2021). *Bidirectional Typing*. ACM Computing Surveys. - Girard, J.-Y. (1972). *Interprétation fonctionnelle et élimination des coupures de l'arithmétique d'ordre supérieur*. Thèse d'État, Université Paris 7. - Hurkens, A. J. C. (1995). *A Simplification of Girard's Paradox*. TLCA 1995. - Kiselyov, O., & Ishii, H. (2015). *Freer Monads, More Extensible Effects*. Haskell Symposium 2015. - Plotkin, G., & Pretnar, M. (2009). *Handlers of Algebraic Effects*. ESOP 2009. - de Bruijn, N. (1972). *Lambda Calculus Notation with Nameless Dummies*. Indagationes Mathematicae. - Martin-Löf, P. (1984). *Intuitionistic Type Theory*. Bibliopolis. - Findler, R., & Felleisen, M. (2002). *Contracts for Higher-Order Functions*. ICFP 2002. ### Kernel Architecture This chapter describes the type-checking kernel: its pipeline, its primitives, and how to write verified implementations that the kernel checks and extracts back to usable Nix functions. ## Two kernels nix-effects is a freer-monad effect layer with a dependent type checker on top. There are two kernels: - The **effects kernel** (`src/kernel.nix`, `src/comp.nix`, `src/queue.nix`) implements the freer monad with FTCQueue. It defines the `Computation` ADT (`Pure a | Impure (Effect x) (FTCQueue x a)`) and the monadic operations `pure`, `impure`, `send`, `bind`, `map`, `seq`, `pipe`, `kleisli`. - The **type-checking kernel** (`src/tc/`) implements Martin-Löf type theory with normalization by evaluation and bidirectional checking. Six modules: `term`, `eval`, `value`, `quote`, `conv`, `check` (with `check/` split into `check`, `infer`, `type`). The type-checking kernel's higher layers use the effects kernel for error reporting. When `check` or `infer` rejects a term, it does not throw — it calls `send "typeError" { msg; expected; got; term; }`, producing an `Impure` computation. Handlers in `src/effects/typecheck.nix` (`strict`, `collecting`, and others) interpret that request with different strategies: `strict` throws on the first error, `collecting` accumulates errors into handler state. The TCB (`eval`, `quote`, `conv`) never sends effects — it only throws on kernel-invariant violations. ``` Type system API (src/types/) Record, ListOf, DepRecord, refined, Pi, Sigma, ... | | elaboration (src/tc/elaborate/, src/tc/hoas/) v Type-checking kernel (MLTT, src/tc/) | | typeError sent as effect request v Effects kernel (freer monad + FTCQueue, src/kernel.nix) | | handler (strict / collecting / ...) interprets effects v Pure Nix ``` Every `fx.types` type carries a `_kernel` field — a HOAS tree that elaborates to a kernel type. `.check` is derived from `decide(_kernel, v)`; `.validate` wraps `decide` in a `typeCheck` effect so handlers can do blame-annotated reporting; `.prove` type-checks HOAS proof terms; `verifyAndExtract` runs the full pipeline (check → eval → extract) to produce a Nix value from a HOAS implementation. Refinement types add a `guard` predicate that runs alongside the kernel check (`check = kernelDecide(v) ∧ guard(v)`), handling constraints the kernel cannot express. ## The kernel pipeline The kernel implements normalization by evaluation (NbE) with bidirectional type checking. Six modules, each with a single responsibility: ``` term.nix --> eval.nix --> value.nix | quote.nix --> term.nix | conv.nix | check.nix ``` | Module | Function | Signature | |--------|----------|-----------| | `term.nix` | Term constructors | `mkVar`, `mkPi`, `mkLam`, `mkApp`, ... | | `eval.nix` | Evaluation | `Env × Tm -> Val` | | `value.nix` | Value constructors | `VLam`, `VPi`, `VPair`, `VZero`, ... | | `quote.nix` | Quotation | `ℕ × Val -> Tm` | | `conv.nix` | Conversion checking | `ℕ × Val × Val -> Bool` | | `check.nix` | Type checking | `Ctx × Tm × Val -> Tm` / `Ctx × Tm -> Tm × Val` | **Terms** (`Tm`) are the syntax — de Bruijn indexed expressions with explicit binding structure. **Values** (`Val`) are the semantics — fully normalized forms where lambdas are Nix closures (this is the NbE trick). **Evaluation** converts terms to values. **Quotation** reads values back to terms. **Conversion** checks whether two values are definitionally equal by comparing their quoted forms. The type checker is bidirectional: - `check(Γ, t, T)` — check that term `t` has type `T` (type-directed) - `infer(Γ, t)` — infer the type of term `t` (term-directed) - `checkTypeLevel(Γ, T)` — compute the universe level of a type ### Trust model The kernel has three layers with decreasing trust requirements: **Layer 0 — Trusted Computing Base.** `eval`, `quote`, `conv`. Pure functions. No side effects. No imports from the effect system. Bugs here compromise soundness. **Layer 1 — Semi-trusted.** `check`, `infer`, `checkTypeLevel`. Uses the TCB and sends effects for error reporting. Bugs may produce wrong error messages or reject valid terms, but cannot cause unsoundness. **Layer 2 — Untrusted.** The elaborator (`hoas.nix`, `elaborate.nix`). Translates surface syntax to core terms. Can have arbitrary bugs without compromising safety — the kernel verifies the output. ## Axiomatized primitives The kernel understands seven Nix primitive types as axioms. Each has a type former, a literal constructor, and a typing rule. None have eliminators — the kernel says "String is a type at level 0" and "a string literal inhabits String" but cannot structurally decompose these values. | Nix type | Kernel type | Literal | Level | |----------|-------------|---------|-------| | `string` | `String` | `StringLit(s)` | 0 | | `int` | `Int` | `IntLit(n)` | 0 | | `float` | `Float` | `FloatLit(f)` | 0 | | `set` | `Attrs` | `AttrsLit` | 0 | | `path` | `Path` | `PathLit` | 0 | | `lambda` | `Function` | `FnLit` | 0 | | (any) | `Any` | `AnyLit` | 0 | The structural types with kernel introduction and elimination rules are `Nat`, `Unit`, `List`, `Sum`, `Sigma`, `Pi`, and `Eq`, together with the indexed-description family (`Desc I`, `μ`, `desc-ind`). `Bool` and `Void` are derived, not primitive: `H.bool` elaborates to `μ ⊤ (plus (retI tt) (retI tt)) tt` (a plus-coproduct of two unit points), and `H.void` elaborates to `Fin 0`. Their eliminators — `H.boolElim`, `H.absurd` — are defined in `src/tc/hoas/combinators.nix` in terms of `desc-ind` and a direct `J`-transport respectively. This gives the kernel enough structure to compute with natural numbers, booleans, lists, pairs, functions, and user-defined inductive families, but treats strings, integers, and other Nix-native types as opaque tokens. Axiomatized primitives are critical for real-world use. Without them, verified modules can only work over `Nat`/`Bool`/`List`/`Sigma`/`Sum`. With them, modules can handle ports (`Int`), service names (`String`), configuration records (nested `Sigma`/`Attrs`), and so on. ## HOAS: the surface API Writing de Bruijn indexed terms by hand is error-prone. The HOAS (Higher-Order Abstract Syntax) layer lets you use Nix lambdas for variable binding. The public API is `fx.types.hoas`. ### Type combinators ```nix H = fx.types.hoas; H.nat # Nat H.bool # Bool H.string # String H.int_ # Int H.float_ # Float H.unit # Unit (nullary product) H.void # Void (empty type) H.listOf H.nat # List Nat H.sum H.nat H.bool # Nat + Bool H.forall "x" H.nat (_: H.bool) # Π(x : Nat). Bool H.sigma "x" H.nat (_: H.bool) # Σ(x : Nat). Bool H.u 0 # U₀ (universe of small types) ``` ### Term combinators ```nix # Lambda: λ(x : Nat). x H.lam "x" H.nat (x: x) # Application H.app f arg # Natural number literals H.zero # 0 H.succ H.zero # 1 H.natLit 42 # 42 (sugar for 42 succs) # Boolean literals H.true_ H.false_ # Pairs H.pair fst snd # Projections H.fst_ p H.snd_ p # Sum injections H.inl leftTy rightTy term H.inr leftTy rightTy term # String / Int / Float literals H.stringLit "hello" H.intLit 42 H.floatLit 3.14 # Type annotation H.ann term type ``` ### Eliminators ```nix # Natural number induction H.ind motive base step scrut # Boolean elimination (k : Level is the motive's universe level) H.boolElim k motive onTrue onFalse scrut # List elimination H.listElim elemType motive onNil onCons scrut # Sum elimination H.sumElim leftTy rightTy motive onLeft onRight scrut # Equality elimination (J) H.j type lhs motive base rhs eq ``` ### How HOAS compiles Binding combinators produce HOAS markers — lightweight attrsets that stand for bound variables at a specific depth. The `elaborate` function (in `hoas.nix`) converts these to de Bruijn indexed `Tm` terms: ``` H.lam "x" H.nat (x: H.succ x) │ │ HOAS: x is a marker { _hoas = true; level = 0; } │ ▼ elaborate (depth=0) │ │ marker at level 0 -> T.mkVar(0 - 0 - 1) = T.mkVar(0) │ ▼ Lam("x", Nat, Succ(Var(0))) ← de Bruijn term ``` The elaboration of nested binding forms is trampolined via `builtins.genericClosure` for stack safety on deeply nested terms. ## The elaboration bridge The elaboration bridge (`elaborate.nix`) connects the user-facing type system to the kernel. It has six operations: | Operation | Signature | Direction | |-----------|-----------|-----------| | `elaborateType` | `FxType -> HoasTree` | type system -> kernel | | `elaborateValue` | `HoasTree × NixVal -> HoasTree` | Nix value -> kernel term | | `extract` | `HoasTree × Val -> NixValue` | kernel value -> Nix value | | `decide` | `HoasTree × NixVal -> Bool` | decision procedure | | `decideType` | `FxType × NixVal -> Bool` | elaborate type, then decide | | `verifyAndExtract` | `HoasTree × HoasTree -> NixValue` | full pipeline | ### elaborateType Converts an `fx.types` type into a HOAS tree. Dispatches on three things, in order: 1. The `_kernel` field (types built via `mkType` with `kernelType`) 2. Structural fields (Pi: `domain`/`codomain`, Sigma: `fstType`/`sndFamily`) 3. Name convention (`"Bool"` -> `H.bool`, `"String"` -> `H.string`, etc.) ### elaborateValue Converts a Nix value into a HOAS term, guided by a HOAS type. For example, given `H.nat` and the Nix integer `3`, produces `H.succ (H.succ (H.succ H.zero))`. Given `H.string` and `"hello"`, produces `H.stringLit "hello"`. ### extract — the reverse direction `extract` converts kernel values back to Nix values. It is the reverse of `elaborateValue`. This is where the verification story becomes interesting: you write an implementation as a HOAS term, the kernel verifies it, `eval` produces a kernel value, and `extract` converts the result to a usable Nix value. ```nix # extract : HoasTree -> Val -> NixValue extract H.nat (VSucc (VSucc VZero)) # -> 2 extract H.bool (VDescCon ... (VInl ... VRefl)) # -> true # (H.bool is derived, # so true/false are # plus-encoded μ values) extract H.string (VStringLit "hi") # -> "hi" extract (H.listOf H.nat) (VCons ...) # -> [1 2 3] extract (H.forall "x" ...) (VLam ...) # -> Nix function (!) ``` The Pi case is the most important. Extracting a verified function produces a Nix function that: 1. Elaborates its argument into a kernel value (Nix -> kernel) 2. Applies the kernel-verified closure 3. Extracts the result back (kernel -> Nix) Correct by construction — the kernel verified the term, `eval` produced the closure, `extract` wraps with value conversion at the boundaries. ### decide The decision procedure. Returns `true` iff both elaboration and kernel type-checking succeed: ```nix decide = hoasTy: value: let result = builtins.tryEval ( let hoasVal = elaborateValue hoasTy value; checked = H.checkHoas hoasTy hoasVal; in !(checked ? error) ); in result.success && result.value; ``` This is the function that `mkType` uses to derive `.check`. Every type's `.check` is `v: decide(kernelType, v)` — no hand-written predicates. ### verifyAndExtract — the full pipeline Type-check a HOAS term against a HOAS type, then extract the result: ```nix verifyAndExtract = hoasTy: hoasImpl: let checked = H.checkHoas hoasTy hoasImpl; in if checked ? error then throw "verifyAndExtract: type check failed" else let tm = H.elab hoasImpl; # HOAS -> de Bruijn val = E.eval [] tm; # evaluate to Val in extract hoasTy val; # Val -> Nix value ``` ## Convenience combinators Raw HOAS is verbose. The convenience combinator layer (`fx.types.verified`, accessed as `v`) provides sugar that produces valid HOAS term trees: ```nix v = fx.types.verified; H = fx.types.hoas; ``` ### Literals ```nix v.nat 5 # H.natLit 5 v.str "hello" # H.stringLit "hello" v.int_ 42 # H.intLit 42 v.float_ 3.14 # H.floatLit 3.14 v.true_ # H.true_ v.false_ # H.false_ v.null_ # H.tt (unit) ``` ### Binding forms ```nix # Lambda: λ(x : Nat). body v.fn "x" H.nat (x: body) # Let: let x : Nat = 5 in body v.let_ "x" H.nat (v.nat 5) (x: body) ``` ### Eliminators with inferred motives The convenience combinators construct the required motive automatically from the result type. The motive is always constant (non-dependent): `λ_. resultTy`. ```nix # Boolean: if b then 1 else 0 v.if_ H.nat v.true_ { then_ = v.nat 1; else_ = v.nat 0; } # Natural number: pattern match on n v.match H.nat n { zero = v.nat 42; succ = k: ih: H.succ ih; } # List: fold over elements v.matchList H.nat H.nat list { nil = v.nat 0; cons = h: t: ih: H.succ ih; # count elements } # Sum: case split v.matchSum H.nat H.bool H.nat scrut { left = x: H.succ x; right = _: v.nat 0; } ``` ### Derived combinators ```nix # Map: apply f to each element v.map H.nat H.nat succFn myList # Fold: combine elements with accumulator v.fold H.nat H.nat (v.nat 0) addFn myList # Filter: keep elements matching predicate v.filter H.nat isZeroFn myList ``` ### Pairs and sums ```nix v.pair fstTerm sndTerm sigmaType v.fst p v.snd p v.inl leftTy rightTy term v.inr leftTy rightTy term v.app f arg ``` ### The verify pipeline `v.verify` wraps `verifyAndExtract` — type-check and extract in one call: ```nix v.verify type implementation # = verifyAndExtract type implementation ``` ## Writing verified implementations The key insight: instead of writing Nix functions and trying to elaborate them into kernel terms (which fails for closures), write implementations as HOAS terms and **extract** Nix functions out. This is the approach taken by Coq (extraction), Idris (compilation), and F\*. ### Example: verified addition ```nix let H = fx.types.hoas; v = fx.types.verified; addTy = H.forall "m" H.nat (_: H.forall "n" H.nat (_: H.nat)); addImpl = v.fn "m" H.nat (m: v.fn "n" H.nat (n: v.match H.nat m { zero = n; succ = _k: ih: H.succ ih; })); # Kernel verifies: addImpl : Π(m:Nat). Π(n:Nat). Nat # Then extracts a 2-argument Nix function add = v.verify addTy addImpl; in add 2 3 # -> 5 ``` What happens step by step: 1. `v.verify` calls `H.checkHoas addTy addImpl` — the kernel type-checks the HOAS term against the HOAS type 2. `H.elab addImpl` converts HOAS to de Bruijn indexed `Tm` 3. `E.eval [] tm` evaluates to a `VLam` value (a Nix closure via NbE) 4. `extract addTy val` wraps the `VLam` as a Nix function that elaborates arguments at call boundaries The resulting `add` is an ordinary Nix function. Call it with Nix integers. At each call, the argument is elaborated into a kernel value, the verified closure runs, and the result is extracted back. ### Example: verified list operations ```nix let H = fx.types.hoas; v = fx.types.verified; # Successor function: Nat -> Nat succFn = v.fn "x" H.nat (x: H.succ x); # Map successor over a list input = H.cons H.nat (v.nat 0) (H.cons H.nat (v.nat 1) (H.cons H.nat (v.nat 2) (H.nil H.nat))); result = v.verify (H.listOf H.nat) (v.map H.nat H.nat succFn input); in result # -> [1 2 3] ``` ### Example: verified filter ```nix let H = fx.types.hoas; v = fx.types.verified; # isZero : Nat -> Bool isZero = v.fn "n" H.nat (n: v.match H.bool n { zero = v.true_; succ = _k: _ih: v.false_; }); input = H.cons H.nat (v.nat 0) (H.cons H.nat (v.nat 1) (H.cons H.nat (v.nat 0) (H.cons H.nat (v.nat 2) (H.nil H.nat)))); result = v.verify (H.listOf H.nat) (v.filter H.nat isZero input); in result # -> [0 0] ``` ## The verification spectrum The architecture supports a spectrum of assurance levels. Each level offers a different trade-off between verification strength and implementation cost. ### Level 1: Contract The baseline. Types check values at introduction via `.check` (which calls `decide` under the hood). No HOAS, no proof terms — just write normal Nix code and let the type system validate data at boundaries. ```nix let inherit (fx.types) Int String refined; Port = refined "Port" Int (x: x >= 1 && x <= 65535); # Refinement with string guard — kernel checks String, guard checks membership LogLevel = refined "LogLevel" String (x: builtins.elem x [ "debug" "info" "warn" "error" ]); in { ok = Port.check 8080; # true — decide says Int, guard says in range bad = Port.check 99999; # false — guard rejects (> 65535) wrong = Port.check "http"; # false — decide rejects (not Int) info = LogLevel.check "info"; # true trace = LogLevel.check "trace"; # false — not in the allowed set # Effectful validation with blame context result = fx.run (Port.validate 99999) fx.effects.typecheck.collecting []; # result.state = [ { context = "Port"; message = "Expected Port, got int"; ... } ] } ``` **Cost:** Zero — write normal Nix. The kernel runs behind the scenes. Refinement types compose: `ListOf Port` checks that every element is an integer in range. The kernel elaborates the list, the guard runs per element. ### Level 2: Boundary Data values are checked by the kernel at module interfaces. Types carry `kernelType` and `.check` is derived from the kernel's `decide` procedure. This is what every type does by default. ```nix let inherit (fx.types) Bool Int String ListOf DepRecord refined; FIPSCipher = refined "FIPSCipher" String (x: builtins.elem x [ "AES-256-GCM" "AES-128-GCM" "AES-256-CBC" ]); # Dependent record: when fipsMode is true, cipherSuites must be FIPS-approved. # The kernel elaborates the record to a Sigma chain, checks each field's type # against its kernelType, and the guard on FIPSCipher validates membership. ServiceConfig = DepRecord [ { name = "fipsMode"; type = Bool; } { name = "cipherSuites"; type = self: if self.fipsMode then ListOf FIPSCipher else ListOf String; } ]; in { ok = ServiceConfig.checkFlat { fipsMode = true; cipherSuites = [ "AES-256-GCM" ]; }; # true — kernel verifies each cipher is a valid FIPSCipher bad = ServiceConfig.checkFlat { fipsMode = true; cipherSuites = [ "3DES" ]; }; # false — "3DES" fails the FIPSCipher guard lax = ServiceConfig.checkFlat { fipsMode = false; cipherSuites = [ "ChaCha20" "RC4" ]; }; # true — non-FIPS mode accepts any string } ``` **Cost:** Low — add `kernelType` to custom types (built-in types already have it). The dependent record pattern shows how boundary checking scales: the kernel handles structural verification, guards handle domain predicates, and the dependency between fields is resolved at check time. ### Level 3: Property Universal properties verified via proof terms. Write proofs in HOAS that the kernel checks. The proof term is separate from the implementation — you write separate Nix code alongside, and the kernel verifies that the stated property holds. ```nix let H = fx.types.hoas; inherit (H) nat bool eq forall refl checkHoas; # Define addition by structural recursion on the first argument add = m: n: H.ind (H.lam "_" nat (_: nat)) n (H.lam "k" nat (_: H.lam "ih" nat (ih: H.succ ih))) m; not_ = b: H.boolElim 0 (H.lam "_" bool (_: bool)) H.false_ H.true_ b; in { # Prove: 3 + 5 = 8 # The kernel normalizes add(3,5) via NatElim, arrives at 8, # and confirms Refl witnesses Eq(Nat, 8, 8). arithmetic = (checkHoas (eq nat (add (H.natLit 3) (H.natLit 5)) (H.natLit 8)) refl).tag == "refl"; # Prove: not(not(true)) = true # The kernel evaluates two BoolElim steps and confirms the result. doubleNeg = (checkHoas (eq bool (not_ (not_ H.true_)) H.true_) refl).tag == "refl"; # Prove: append([1,2], [3]) = [1,2,3] # ListElim unfolds the first list, cons-ing each element onto [3]. listAppend = let list12 = H.cons nat (H.natLit 1) (H.cons nat (H.natLit 2) (H.nil nat)); list3 = H.cons nat (H.natLit 3) (H.nil nat); list123 = H.cons nat (H.natLit 1) (H.cons nat (H.natLit 2) (H.cons nat (H.natLit 3) (H.nil nat))); append = xs: ys: H.listElim nat (H.lam "_" (H.listOf nat) (_: H.listOf nat)) ys (H.lam "h" nat (h: H.lam "t" (H.listOf nat) (_: H.lam "ih" (H.listOf nat) (ih: H.cons nat h ih)))) xs; in (checkHoas (eq (H.listOf nat) (append list12 list3) list123) refl).tag == "refl"; } ``` **Cost:** Medium — write proofs in HOAS. The proofs are separate from production code, so you can add them incrementally to an existing codebase without rewriting anything. ### Level 4: Full The implementation IS the proof term. Write the entire implementation in HOAS, the kernel verifies it, and `extract` produces a Nix function that is correct by construction. The extracted function is plain Nix — no kernel overhead at call time. ```nix let H = fx.types.hoas; v = fx.types.verified; RecTy = H.record [ { name = "name"; type = H.string; } { name = "target"; type = H.string; } ]; # Verified record validator: checks if two string fields match. # The kernel type-checks the implementation against its type # (Record -> Bool), verifies that field projections are well-typed, # and confirms strEq composes correctly. Then extracts a Nix function. matchFn = v.verify (H.forall "r" RecTy (_: H.bool)) (v.fn "r" RecTy (r: v.strEq (v.field RecTy "name" r) (v.field RecTy "target" r))); # Verified addition: structural recursion extracted as Nix function add = v.verify (H.forall "m" H.nat (_: H.forall "n" H.nat (_: H.nat))) (v.fn "m" H.nat (m: v.fn "n" H.nat (n: v.match H.nat m { zero = n; succ = _k: ih: H.succ ih; }))); in { sum = add 2 3; # -> 5, correct by construction yes = matchFn { name = "hello"; target = "hello"; }; # -> true no = matchFn { name = "hello"; target = "world"; }; # -> false } ``` **Cost:** High — write the implementation in HOAS. Best reserved for code where the cost is justified by the assurance. See the [Proof Guide](/nix-effects/guide/proof-guide) for a progressive tutorial from simple proofs through the J eliminator to verified extraction of plain Nix functions. ## How mkType derives .check Every type is built by `mkType` (in `foundation.nix`). The kernel type IS the type. `.check` is its decision procedure, derived mechanically: ``` _kernel : HoasType ← the type IS this check : Value -> Bool ← derived from decide(kernelType, value) kernelCheck : Value -> Bool ← same as check (legacy alias) prove : HoasTerm -> Bool ← kernel proof checking universe : Int ← computed from checkTypeLevel(kernelType) ``` For refinement types, an optional `guard` adds a runtime predicate on top of the kernel check: `check = decide(kernelType, v) && guard(v)`. The guard handles predicates the kernel cannot express — for example, `x >= 0` for natural numbers, or membership in a finite set for validated strings. ## Limitations **Nix closures are opaque.** `decide(H.forall ..., f)` can only check `builtins.isFunction f`. For full function verification, write the function in HOAS and use `v.verify` to extract. **Refinement predicates are opaque.** The kernel cannot represent `x >= 0` as a type-level assertion. Refinement types always need a hand-written guard predicate. **`builtins.tryEval` is limited.** It only catches `throw` and `assert false`. Cross-type comparison errors, boolean coercion errors, and missing attribute access crash Nix uncatchably. This affects `decide` for types whose elaboration might trigger such errors. **Dependent extraction is limited.** Extracting a dependent Pi or Sigma requires a sentinel test to detect non-dependence. If the type family is truly dependent, extraction throws and requires explicit type annotation. **Opaque types cannot be extracted.** `Attrs`, `Path`, `Function`, and `Any` are axiomatized — the kernel knows they exist but discards their payloads. Extracting a value of these types throws. They work for type-checking (deciding membership) but not for the full verify-and-extract pipeline. **Extraction has boundary cost.** Extracted functions elaborate their arguments at every call (Nix -> kernel value -> apply -> extract -> Nix). For hot paths, the contract layer's `.check` fast path is more efficient. ### Kernel Formal Specification This document is the contract the implementation must satisfy. Every typing rule, compute rule, and conversion rule is stated precisely. Every test is derived from this spec. Every invariant the kernel must maintain is listed. The spec uses standard type-theoretic notation. No Nix code appears here — this document is reviewable by anyone who reads dependent type theory, regardless of implementation language. --- ## 1. Trust Model The kernel has three layers with strictly decreasing trust requirements. **Layer 0 — Trusted Computing Base (TCB).** The evaluator, quotation, and conversion checker. Pure functions. No side effects. No imports from the effect system. Bugs here compromise soundness. Every line must be auditable. - `eval : Env × Tm → Val` - `quote : ℕ × Val → Tm` - `conv : ℕ × Val × Val → Bool` **Layer 1 — Semi-trusted.** The bidirectional type checker. Uses the TCB and sends effects for error reporting. Bugs here may produce wrong error messages or reject valid terms, but cannot cause unsoundness (the TCB rejects ill-typed terms independently). - `check : Ctx × Tm × Val → Tm` - `infer : Ctx × Tm → Tm × Val` - `checkTypeLevel : Ctx × Tm → Tm × ℕ` **Layer 2 — Untrusted.** The elaborator. Translates surface syntax (named variables, implicit arguments, level inference, eta-insertion) into fully explicit core terms. Can have arbitrary bugs without compromising safety — the kernel verifies the output. ### Failure modes | Condition | Response | Rationale | |-----------|----------|-----------| | Kernel invariant violation | `throw` (crash) | TCB may be buggy; cannot trust own output | | User type error | Effect `typeError` | Normal operation; handler decides policy | | Normalization budget exceeded | `throw` (crash) | Layer 0 has no effect access; `tryEval` catches it | | Unknown term tag | `throw` (crash) | Exhaustiveness violation = kernel bug | --- ## 2. Syntax ### 2.1 Terms (Tm) The core term language. All binding uses de Bruijn indices. Name annotations are cosmetic (for error messages only). ``` Tm ::= -- Variables and binding | Var(i : ℕ) -- de Bruijn index | Let(n : Name, A : Tm, t : Tm, u : Tm) -- let n : A = t in u -- Functions | Pi(n : Name, A : Tm, B : Tm) -- Π(n : A). B | Lam(n : Name, A : Tm, t : Tm) -- λ(n : A). t | App(t : Tm, u : Tm) -- t u -- Pairs | Sigma(n : Name, A : Tm, B : Tm) -- Σ(n : A). B | Pair(a : Tm, b : Tm, T : Tm) -- (a, b) as T | Fst(t : Tm) -- π₁ t | Snd(t : Tm) -- π₂ t -- Natural numbers | Nat -- ℕ | Zero -- 0 | Succ(t : Tm) -- S t | NatElim(P : Tm, z : Tm, s : Tm, n : Tm) -- elim_ℕ(P, z, s, n) -- Lists | List(A : Tm) -- List A | Nil(A : Tm) -- nil_A | Cons(A : Tm, h : Tm, t : Tm) -- cons_A h t | ListElim(A : Tm, P : Tm, n : Tm, c : Tm, l : Tm) -- elim_List(A, P, n, c, l) -- Unit | Unit -- ⊤ | Tt -- tt -- Sum | Sum(A : Tm, B : Tm) -- A + B | Inl(A : Tm, B : Tm, t : Tm) -- inl t | Inr(A : Tm, B : Tm, t : Tm) -- inr t | SumElim(A : Tm, B : Tm, P : Tm, l : Tm, r : Tm, s : Tm) -- elim_+(A, B, P, l, r, s) -- Identity | Eq(A : Tm, a : Tm, b : Tm) -- Id_A(a, b) | Refl -- refl | J(A : Tm, a : Tm, P : Tm, pr : Tm, b : Tm, eq : Tm) -- J(A, a, P, pr, b, eq) -- Levels (Tarski-style sort of universe levels — see §6.6, §8.5) | Level -- the Level sort itself, lives at U(0) | LevelZero -- 0 | LevelSuc(k : Tm) -- successor: k+1 | LevelMax(a : Tm, b : Tm) -- join in the level semilattice -- Universes (level-indexed, k : Level) | U(k : Tm) -- Type_k -- Descriptions (universe-polymorphic; see §7.6) | Desc(K : Tm, I : Tm) -- Desc^K I — descriptions over index type I | DescRet(j : Tm) -- ret(j) — leaf returning at index j | DescArg(K : Tm, S : Tm, T : Tm) -- arg^K S T — non-recursive Π over S : U(K) | DescRec(j : Tm, D : Tm) -- rec(j) D — recursive child at index j, then D | DescPi(K : Tm, S : Tm, f : Tm, D : Tm) -- π^K S f D — recursive Π over S : U(K), indexed by f | DescPlus(A : Tm, B : Tm) -- A + B — first-class binary coproduct of descriptions | DescElim(K : Tm, P : Tm, onRet : Tm, onArg : Tm, onRec : Tm, onPi : Tm, onPlus : Tm, scrut : Tm) -- description recursor at level K | DescInd(D : Tm, motive : Tm, step : Tm, i : Tm, scrut : Tm) -- generic μ-induction over D at index i -- μ-types (description-induced datatypes) | Mu(I : Tm, D : Tm, i : Tm) -- μ I D i — the i-th type in the family classified by D | DescCon(D : Tm, i : Tm, payload : Tm) -- introduction at index i with payload : interp(D, i) -- Annotations | Ann(t : Tm, A : Tm) -- (t : A) -- Axiomatized primitive types | String -- string type | Int -- integer type | Float -- float type | Attrs -- attribute set type | Path -- path type | Function -- opaque function type | Any -- dynamic/any type -- String operations | StrEq(lhs : Tm, rhs : Tm) -- string equality: lhs == rhs → H.bool (derived) -- Primitive literals | StringLit(s) -- string literal | IntLit(n) -- integer literal | FloatLit(f) -- float literal | AttrsLit -- attribute set literal | PathLit -- path literal | FnLit -- opaque function literal | AnyLit -- any literal ``` ### 2.2 Binding convention In `Pi(n, A, B)`, `Lam(n, A, t)`, `Sigma(n, A, B)`, and `Let(n, A, t, u)`: the body (`B`, `t`, or `u`) binds one variable. Index 0 in the body refers to the bound variable. All other indices shift by 1. In `NatElim(P, z, s, n)`: `P` binds 0 variables (it's a function term `ℕ → U`). `z` binds 0 variables. `s` binds 0 variables (it's a function term). `n` binds 0 variables. All eliminators take their arguments as closed terms (no implicit binding). The motive is a function term, not a binder. ### 2.3 De Bruijn index conventions Indices count inward from the use site: 0 = most recent binder. Example: `λ(x : A). λ(y : B). x` is `Lam(x, A, Lam(y, B, Var(1)))`. --- ## 3. Values (Semantic Domain) Values are the result of evaluation. They use de Bruijn **levels** (counting outward from the top of the context) instead of indices. ``` Val ::= -- Functions | VPi(n : Name, A : Val, cl : Closure) -- Π type | VLam(n : Name, A : Val, cl : Closure) -- λ abstraction -- Pairs | VSigma(n : Name, A : Val, cl : Closure) -- Σ type | VPair(a : Val, b : Val) -- pair value -- Natural numbers | VNat | VZero | VSucc(v : Val) -- Lists | VList(A : Val) | VNil(A : Val) | VCons(A : Val, h : Val, t : Val) -- Unit | VUnit | VTt -- Sum | VSum(A : Val, B : Val) | VInl(A : Val, B : Val, v : Val) | VInr(A : Val, B : Val, v : Val) -- Identity | VEq(A : Val, a : Val, b : Val) | VRefl -- Levels (Tarski-style sort of universe levels — see §6.6, §8.5) | VLevel -- the Level sort itself | VLevelZero -- 0 | VLevelSuc(pred : Val) -- successor | VLevelMax(lhs : Val, rhs : Val) -- join -- Universes (level-indexed, k : VLevel) | VU(k : Val) -- Descriptions (universe-polymorphic; see §7.6) | VDesc(K : Val, I : Val) -- Desc^K I | VDescRet(j : Val) -- ret(j) | VDescArg(K : Val, S : Val, T : Closure) -- arg^K S T, T : S → Desc^K I as a closure | VDescRec(j : Val, D : Val) -- rec(j) D | VDescPi(K : Val, S : Val, f : Val, D : Closure) -- π^K S f D, f : S → I as a value, D : S → Desc^K I | VDescPlus(A : Val, B : Val) -- A + B -- μ-types | VMu(I : Val, D : Val, i : Val) -- μ I D i | VDescCon(D : Val, i : Val, d : Val) -- introduction at index i with payload d -- Axiomatized primitive types | VString | VInt | VFloat | VAttrs | VPath | VFunction | VAny -- Primitive literal values | VStringLit(s) | VIntLit(n) | VFloatLit(f) | VAttrsLit | VPathLit | VFnLit | VAnyLit -- Neutrals (stuck computations) | VNe(level : ℕ, spine : [Elim]) Elim ::= | EApp(v : Val) | EFst | ESnd | ENatElim(P : Val, z : Val, s : Val) | EListElim(A : Val, P : Val, n : Val, c : Val) | ESumElim(A : Val, B : Val, P : Val, l : Val, r : Val) | EJ(A : Val, a : Val, P : Val, pr : Val, b : Val) | EStrEq(arg : Val) | EDescElim(K : Val, motive : Val, onRet : Val, onArg : Val, onRec : Val, onPi : Val, onPlus : Val) -- carries K so the spine round-trips back to DescElim K | EDescInd(D : Val, motive : Val, step : Val, i : Val) Closure ::= (env : Env, body : Tm) Env ::= [Val] -- list indexed by de Bruijn index ``` ### 3.1 Level/index relationship De Bruijn levels count from the outermost binder: 0 = first-ever bound variable. Levels are stable under context extension. Conversion between index and level: ``` index = depth - level - 1 level = depth - index - 1 ``` where `depth` is the current binding depth (length of the context). ### 3.2 Fresh variables A fresh variable at depth `d` is `VNe(d, [])` — a neutral with level `d` and empty spine. Used in conversion checking to compare under binders. ### 3.3 Closure instantiation ``` instantiate((env, body), v) = eval([v] ++ env, body) ``` --- ## 4. Evaluation Rules `eval(ρ, t)` interprets term `t` in environment `ρ`, producing a value. All rules are deterministic. ### 4.1 Variables and let ``` eval(ρ, Var(i)) = ρ[i] eval(ρ, Let(n, A, t, u)) = eval([eval(ρ, t)] ++ ρ, u) eval(ρ, Ann(t, A)) = eval(ρ, t) ``` ### 4.2 Functions ``` eval(ρ, Pi(n, A, B)) = VPi(n, eval(ρ, A), (ρ, B)) eval(ρ, Lam(n, A, t)) = VLam(n, eval(ρ, A), (ρ, t)) eval(ρ, App(t, u)) = vApp(eval(ρ, t), eval(ρ, u)) ``` where `vApp` performs beta reduction or accumulates: ``` vApp(VLam(n, A, cl), v) = instantiate(cl, v) vApp(VNe(l, sp), v) = VNe(l, sp ++ [EApp(v)]) vApp(_, _) = THROW "kernel bug: vApp on non-function" ``` ### 4.3 Pairs ``` eval(ρ, Sigma(n, A, B)) = VSigma(n, eval(ρ, A), (ρ, B)) eval(ρ, Pair(a, b, T)) = VPair(eval(ρ, a), eval(ρ, b)) eval(ρ, Fst(t)) = vFst(eval(ρ, t)) eval(ρ, Snd(t)) = vSnd(eval(ρ, t)) ``` where: ``` vFst(VPair(a, b)) = a vFst(VNe(l, sp)) = VNe(l, sp ++ [EFst]) vFst(_) = THROW "kernel bug: vFst on non-pair" vSnd(VPair(a, b)) = b vSnd(VNe(l, sp)) = VNe(l, sp ++ [ESnd]) vSnd(_) = THROW "kernel bug: vSnd on non-pair" ``` ### 4.4 Natural numbers ``` eval(ρ, Nat) = VNat eval(ρ, Zero) = VZero eval(ρ, Succ(t)) = VSucc(eval(ρ, t)) -- MUST trampoline for deep naturals eval(ρ, NatElim(P,z,s,n)) = vNatElim(eval(ρ,P), eval(ρ,z), eval(ρ,s), eval(ρ,n)) ``` where: ``` vNatElim(P, z, s, VZero) = z vNatElim(P, z, s, VSucc(n)) = vApp(vApp(s, n), vNatElim(P, z, s, n)) vNatElim(P, z, s, VNe(l,sp)) = VNe(l, sp ++ [ENatElim(P, z, s)]) vNatElim(_, _, _, _) = THROW "kernel bug: vNatElim on non-nat" ``` **Note**: `vNatElim` on `VSucc` recurses. The implementation MUST trampoline this to guarantee O(1) stack depth. ### 4.5 Lists ``` eval(ρ, List(A)) = VList(eval(ρ, A)) eval(ρ, Nil(A)) = VNil(eval(ρ, A)) eval(ρ, Cons(A, h, t)) = VCons(eval(ρ, A), eval(ρ, h), eval(ρ, t)) -- MUST trampoline for deep lists eval(ρ, ListElim(A,P,n,c,l)) = vListElim(eval(ρ,A), eval(ρ,P), eval(ρ,n), eval(ρ,c), eval(ρ,l)) ``` where: ``` vListElim(A, P, n, c, VNil(_)) = n vListElim(A, P, n, c, VCons(_, h, t)) = vApp(vApp(vApp(c, h), t), vListElim(A, P, n, c, t)) vListElim(A, P, n, c, VNe(l, sp)) = VNe(l, sp ++ [EListElim(A, P, n, c)]) vListElim(_, _, _, _, _) = THROW "kernel bug: vListElim on non-list" ``` **Note**: `vListElim` on `VCons` recurses. Must be trampolined. ### 4.6 Unit ``` eval(ρ, Unit) = VUnit eval(ρ, Tt) = VTt ``` Unit has no eliminator in the core. The kernel implements ⊤-η: any neutral of type ⊤ converts against `VTt` (see §6.3). Sound in the type-free conv because conv is always called on values sharing a type; if one side is `VTt`, the shared type is ⊤ and the neutral's only inhabitant is `Tt`. ### 4.7 Sum ``` eval(ρ, Sum(A, B)) = VSum(eval(ρ, A), eval(ρ, B)) eval(ρ, Inl(A, B, t)) = VInl(eval(ρ, A), eval(ρ, B), eval(ρ, t)) eval(ρ, Inr(A, B, t)) = VInr(eval(ρ, A), eval(ρ, B), eval(ρ, t)) eval(ρ, SumElim(A,B,P,l,r,s)) = vSumElim(eval(ρ,A), eval(ρ,B), eval(ρ,P), eval(ρ,l), eval(ρ,r), eval(ρ,s)) ``` where: ``` vSumElim(A, B, P, l, r, VInl(_, _, v)) = vApp(l, v) vSumElim(A, B, P, l, r, VInr(_, _, v)) = vApp(r, v) vSumElim(A, B, P, l, r, VNe(k, sp)) = VNe(k, sp ++ [ESumElim(A, B, P, l, r)]) vSumElim(_, _, _, _, _, _) = THROW "kernel bug: vSumElim on non-sum" ``` ### 4.8 Identity ``` eval(ρ, Eq(A, a, b)) = VEq(eval(ρ, A), eval(ρ, a), eval(ρ, b)) eval(ρ, Refl) = VRefl eval(ρ, J(A, a, P, pr, b, eq)) = vJ(eval(ρ,A), eval(ρ,a), eval(ρ,P), eval(ρ,pr), eval(ρ,b), eval(ρ,eq)) ``` where: ``` vJ(A, a, P, pr, b, VRefl) = pr vJ(A, a, P, pr, b, VNe(l,sp)) = VNe(l, sp ++ [EJ(A, a, P, pr, b)]) vJ(_, _, _, _, _, _) = THROW "kernel bug: vJ on non-refl" ``` ### 4.9 Universes ``` eval(ρ, U(i)) = VU(i) ``` ### 4.10 Axiomatized primitives Type formers evaluate to their corresponding values. Literals carry their payload through. No computation, no recursion — these are axiomatized constants. ``` eval(ρ, String) = VString eval(ρ, Int) = VInt eval(ρ, Float) = VFloat eval(ρ, Attrs) = VAttrs eval(ρ, Path) = VPath eval(ρ, Function) = VFunction eval(ρ, Any) = VAny eval(ρ, StringLit(s)) = VStringLit(s) eval(ρ, IntLit(n)) = VIntLit(n) eval(ρ, FloatLit(f)) = VFloatLit(f) eval(ρ, AttrsLit) = VAttrsLit eval(ρ, PathLit) = VPathLit eval(ρ, FnLit) = VFnLit eval(ρ, AnyLit) = VAnyLit ``` Most primitives have no eliminators. They exist to integrate Nix's native types into the kernel's type system as opaque, axiomatized constants. The exception is String, which has `StrEq` (§4.11). ### 4.11 String equality (StrEq) ``` eval(ρ, StrEq(lhs, rhs)) = vStrEq(eval(ρ, lhs), eval(ρ, rhs)) ``` where: ``` -- trueV / falseV are the plus-encoded derived booleans: -- trueV = VDescCon boolDescV VTt (VInl eqTtV eqTtV VRefl) -- falseV = VDescCon boolDescV VTt (VInr eqTtV eqTtV VRefl) -- where boolDescV = plus-desc of two VDescRet VTt summands. vStrEq(VStringLit(s₁), VStringLit(s₂)) = if s₁ == s₂ then trueV else falseV vStrEq(VNe(l, sp), rhs) = VNe(l, sp ++ [EStrEq(rhs)]) vStrEq(lhs, VNe(l, sp)) = VNe(l, sp ++ [EStrEq(lhs)]) vStrEq(_, _) = THROW "kernel bug: vStrEq on non-string" ``` `StrEq` is a binary predicate on strings. Both arguments must be of type `String`. The result type is the derived `H.bool` — `μ ⊤ (plus (retI tt) (retI tt)) tt` — which is the kernel representation of booleans after their retirement as primitives. Unlike other eliminators, StrEq has no motive: it always returns `H.bool`, not a dependent type. When both arguments are concrete string literals, `vStrEq` reduces to the plus-encoded `true_` or `false_` value by Nix-level string comparison. When either argument is neutral, the neutral's spine is extended with `EStrEq` carrying the other argument. This is sound because `StrEq` is symmetric: `StrEq(a, b) ≡ StrEq(b, a)` for all `a, b : String`. --- ## 5. Quotation Rules `quote(d, v)` converts a value back to a term, converting levels to indices. `d` is the current binding depth. ``` quote(d, VPi(n, A, cl)) = Pi(n, quote(d, A), quote(d+1, instantiate(cl, fresh(d)))) quote(d, VLam(n, A, cl)) = Lam(n, quote(d, A), quote(d+1, instantiate(cl, fresh(d)))) quote(d, VSigma(n, A, cl)) = Sigma(n, quote(d, A), quote(d+1, instantiate(cl, fresh(d)))) quote(d, VPair(a, b)) = Pair(quote(d, a), quote(d, b), _) quote(d, VNat) = Nat quote(d, VZero) = Zero quote(d, VSucc(v)) = Succ(quote(d, v)) -- MUST trampoline for deep naturals quote(d, VList(A)) = List(quote(d, A)) quote(d, VNil(A)) = Nil(quote(d, A)) quote(d, VCons(A, h, t)) = Cons(quote(d, A), quote(d, h), quote(d, t)) -- MUST trampoline for deep lists quote(d, VUnit) = Unit quote(d, VTt) = Tt quote(d, VSum(A, B)) = Sum(quote(d, A), quote(d, B)) quote(d, VInl(A, B, v)) = Inl(quote(d, A), quote(d, B), quote(d, v)) quote(d, VInr(A, B, v)) = Inr(quote(d, A), quote(d, B), quote(d, v)) quote(d, VEq(A, a, b)) = Eq(quote(d, A), quote(d, a), quote(d, b)) quote(d, VRefl) = Refl quote(d, VU(i)) = U(i) quote(d, VString) = String quote(d, VInt) = Int quote(d, VFloat) = Float quote(d, VAttrs) = Attrs quote(d, VPath) = Path quote(d, VFunction) = Function quote(d, VAny) = Any quote(d, VStringLit(s)) = StringLit(s) quote(d, VIntLit(n)) = IntLit(n) quote(d, VFloatLit(f)) = FloatLit(f) quote(d, VAttrsLit) = AttrsLit quote(d, VPathLit) = PathLit quote(d, VFnLit) = FnLit quote(d, VAnyLit) = AnyLit quote(d, VNe(l, sp)) = quoteSp(d, Var(d - l - 1), sp) quoteSp(d, head, []) = head quoteSp(d, head, [EApp(v) | rest]) = quoteSp(d, App(head, quote(d, v)), rest) quoteSp(d, head, [EFst | rest]) = quoteSp(d, Fst(head), rest) quoteSp(d, head, [ESnd | rest]) = quoteSp(d, Snd(head), rest) quoteSp(d, head, [ENatElim(P,z,s) | rest]) = quoteSp(d, NatElim(quote(d,P), quote(d,z), quote(d,s), head), rest) quoteSp(d, head, [EListElim(A,P,n,c) | rest]) = quoteSp(d, ListElim(quote(d,A), quote(d,P), quote(d,n), quote(d,c), head), rest) quoteSp(d, head, [ESumElim(A,B,P,l,r) | rest]) = quoteSp(d, SumElim(quote(d,A), quote(d,B), quote(d,P), quote(d,l), quote(d,r), head), rest) quoteSp(d, head, [EJ(A,a,P,pr,b) | rest]) = quoteSp(d, J(quote(d,A), quote(d,a), quote(d,P), quote(d,pr), quote(d,b), head), rest) quoteSp(d, head, [EStrEq(arg) | rest]) = quoteSp(d, StrEq(head, quote(d, arg)), rest) fresh(d) = VNe(d, []) ``` --- ## 6. Conversion Rules `conv(d, v₁, v₂)` checks definitional equality of two values at binding depth `d`. Returns boolean. **No type information is used** — conversion is purely structural on normalized values. ### 6.1 Structural rules ``` conv(d, VU(i), VU(j)) = (i == j) conv(d, VNat, VNat) = true conv(d, VUnit, VUnit) = true conv(d, VZero, VZero) = true conv(d, VTt, VTt) = true conv(d, VRefl, VRefl) = true conv(d, VSucc(a), VSucc(b)) = conv(d, a, b) conv(d, VString, VString) = true conv(d, VInt, VInt) = true conv(d, VFloat, VFloat) = true conv(d, VAttrs, VAttrs) = true conv(d, VPath, VPath) = true conv(d, VFunction, VFunction) = true conv(d, VAny, VAny) = true conv(d, VStringLit(s₁), VStringLit(s₂)) = (s₁ == s₂) conv(d, VIntLit(n₁), VIntLit(n₂)) = (n₁ == n₂) conv(d, VFloatLit(f₁), VFloatLit(f₂)) = (f₁ == f₂) conv(d, VAttrsLit, VAttrsLit) = true conv(d, VPathLit, VPathLit) = true conv(d, VFnLit, VFnLit) = true conv(d, VAnyLit, VAnyLit) = true ``` ### 6.2 Binding forms To compare under binders, generate a fresh variable and instantiate: ``` conv(d, VPi(_, A₁, cl₁), VPi(_, A₂, cl₂)) = conv(d, A₁, A₂) ∧ conv(d+1, instantiate(cl₁, fresh(d)), instantiate(cl₂, fresh(d))) conv(d, VLam(_, _, cl₁), VLam(_, _, cl₂)) = conv(d+1, instantiate(cl₁, fresh(d)), instantiate(cl₂, fresh(d))) conv(d, VLam(_, _, cl), v) = -- Π-η conv(d+1, instantiate(cl, fresh(d)), vApp(v, fresh(d))) -- only fires when v is not a VLam conv(d, v, VLam(_, _, cl)) = -- Π-η conv(d+1, vApp(v, fresh(d)), instantiate(cl, fresh(d))) -- only fires when v is not a VLam conv(d, VSigma(_, A₁, cl₁), VSigma(_, A₂, cl₂)) = conv(d, A₁, A₂) ∧ conv(d+1, instantiate(cl₁, fresh(d)), instantiate(cl₂, fresh(d))) ``` The two Π-η rules fire when exactly one side is a VLam; both sides being VLam falls through to the symmetric VLam/VLam rule above. Sound because conv is always called on values sharing a type — if one side is VLam, that type is VPi, and the other side's only inhabitants up to definitional equality are its η-expansions. Termination: each rule strictly decreases VLam-depth on the side it fires on, so no nested firing can loop. ### 6.3 Compound values ``` conv(d, VPair(a₁, b₁), VPair(a₂, b₂)) = conv(d, a₁, a₂) ∧ conv(d, b₁, b₂) conv(d, VPair(a, b), VNe(l, sp)) = conv(d, a, vFst(VNe(l, sp))) ∧ conv(d, b, vSnd(VNe(l, sp))) -- Σ-η conv(d, VNe(l, sp), VPair(a, b)) = conv(d, vFst(VNe(l, sp)), a) ∧ conv(d, vSnd(VNe(l, sp)), b) -- Σ-η conv(d, VTt, VNe(_, _)) = true -- ⊤-η conv(d, VNe(_, _), VTt) = true -- ⊤-η conv(d, VList(A₁), VList(A₂)) = conv(d, A₁, A₂) conv(d, VNil(A₁), VNil(A₂)) = conv(d, A₁, A₂) conv(d, VCons(A₁, h₁, t₁), VCons(A₂, h₂, t₂)) = conv(d, A₁, A₂) ∧ conv(d, h₁, h₂) ∧ conv(d, t₁, t₂) conv(d, VSum(A₁, B₁), VSum(A₂, B₂)) = conv(d, A₁, A₂) ∧ conv(d, B₁, B₂) conv(d, VInl(A₁, B₁, v₁), VInl(A₂, B₂, v₂)) = conv(d, A₁, A₂) ∧ conv(d, B₁, B₂) ∧ conv(d, v₁, v₂) conv(d, VInr(A₁, B₁, v₁), VInr(A₂, B₂, v₂)) = conv(d, A₁, A₂) ∧ conv(d, B₁, B₂) ∧ conv(d, v₁, v₂) conv(d, VEq(A₁, a₁, b₁), VEq(A₂, a₂, b₂)) = conv(d, A₁, A₂) ∧ conv(d, a₁, a₂) ∧ conv(d, b₁, b₂) ``` ### 6.4 Neutrals ``` conv(d, VNe(l₁, sp₁), VNe(l₂, sp₂)) = (l₁ == l₂) ∧ convSp(d, sp₁, sp₂) convSp(d, [], []) = true convSp(d, [e₁|r₁], [e₂|r₂]) = convElim(d, e₁, e₂) ∧ convSp(d, r₁, r₂) convSp(d, _, _) = false -- different lengths convElim(d, EApp(v₁), EApp(v₂)) = conv(d, v₁, v₂) convElim(d, EFst, EFst) = true convElim(d, ESnd, ESnd) = true convElim(d, ENatElim(P₁,z₁,s₁), ENatElim(P₂,z₂,s₂)) = conv(d, P₁, P₂) ∧ conv(d, z₁, z₂) ∧ conv(d, s₁, s₂) convElim(d, EListElim(A₁,P₁,n₁,c₁), EListElim(A₂,P₂,n₂,c₂)) = conv(d, A₁, A₂) ∧ conv(d, P₁, P₂) ∧ conv(d, n₁, n₂) ∧ conv(d, c₁, c₂) convElim(d, ESumElim(A₁,B₁,P₁,l₁,r₁), ESumElim(A₂,B₂,P₂,l₂,r₂)) = conv(d, A₁, A₂) ∧ conv(d, B₁, B₂) ∧ conv(d, P₁, P₂) ∧ conv(d, l₁, l₂) ∧ conv(d, r₁, r₂) convElim(d, EJ(A₁,a₁,P₁,pr₁,b₁), EJ(A₂,a₂,P₂,pr₂,b₂)) = conv(d, A₁, A₂) ∧ conv(d, a₁, a₂) ∧ conv(d, P₁, P₂) ∧ conv(d, pr₁, pr₂) ∧ conv(d, b₁, b₂) convElim(d, EStrEq(arg₁), EStrEq(arg₂)) = conv(d, arg₁, arg₂) convElim(_, _, _) = false ``` ### 6.5 Catch-all ``` conv(d, _, _) = false ``` Any pair of values not matching the above rules is not definitionally equal. **Π-eta, Σ-eta, and ⊤-eta are applied** (see §6.2 for Π-η, §6.3 for Σ-η and ⊤-η): `f` converts against `λx. f x` under a fresh binder; a pair `⟨a, b⟩` converts against a neutral `x : Σ` by projecting both sides; and any neutral of type `⊤` converts against `tt`. All three η-rules are sound in the type-free conv because conv is always called on two values sharing a type — the side carrying the canonical form (VLam, VPair, VTt) witnesses the shared type's shape (VPi, VSigma, ⊤), and the other side's η-expansion is its only inhabitant up to definitional equality. **Π-η rationale.** Π-η matches the standard semantic models of MLTT (PER, presheaf, simplicial sets) and is the η-rule consistent with funext: `f ≡ λx. f x` definitionally. Without it, definitional equality on Pi-typed values diverges from the equality the surface language must reason about — every elaborator that produces a function value would have to maintain its own η-normal form before submitting to conv. With it, conv handles the canonical-vs-neutral case by descending under one binder and continues structurally; subsequent ⊤-η, Σ-η, and neutral-vs-neutral rules fire as usual on the body. This composition in particular is what closes assemblies that pair a surface `descPi k S (λ_:S. tt) D` (a 3-arg combinator that fills the kernel's `f : S → ⊤` slot with a constant lambda) against a kernel-reconstructed `descPi k S f D` whose `f` is a case-bound variable. ### 6.6 Level conversion (convLevel) Level expressions form a join-semilattice with `zero` as bottom and `max` as join. `convLevel(d, k₁, k₂)` checks definitional equality of two Level values modulo the semilattice laws. **Fast path** (syntactic equality): ``` convLevel(d, k, k) = true ``` When the same Level value reaches both sides of conv (e.g. a description's level is reused unchanged across recursive children), the syntactic-equality check skips the normaliser entirely. Allocations from re-normalising structurally-identical levels dominate hot CHECK loops, so this short-circuit is non-optional. **Normalisation.** Each Level value is reduced to its canonical form before structural comparison: - `max(k, zero) = max(zero, k) = k` (zero absorption) - `max(k, k) = k` (idempotence) - `suc(max(a, b)) = max(suc(a), suc(b))` (suc distributes over max) - `max` operands are sorted to a canonical order The canonical form is `max(s₁, …, sₙ)` where each `sᵢ = sucᵐ(zero)` or `sᵢ = sucᵐ(VNe(_, _))`, sorted lexicographically. After normalisation the comparison is structural: ``` convLevel(d, k₁, k₂) = (normLevel(k₁) ≡_struct normLevel(k₂)) ``` Used by description and universe CHECK rules (§7.6) to verify that two level expressions denote the same level. --- ## 7. Typing Rules (Bidirectional) ### 7.1 Contexts ``` Ctx ::= { env : Env, -- values for evaluation types : [Val], -- types of bound variables (indexed by de Bruijn) depth : ℕ -- current binding depth } emptyCtx = { env = [], types = [], depth = 0 } extend(Γ, n, A) = { env = [fresh(Γ.depth)] ++ Γ.env, types = [A] ++ Γ.types, depth = Γ.depth + 1 } lookupType(Γ, i) = Γ.types[i] -- THROW if i >= length(Γ.types) ``` ### 7.2 Notation ``` Γ ⊢ t ⇐ A ↝ t' checking mode: check(Γ, t, A) = t' Γ ⊢ t ⇒ A ↝ t' synthesis mode: infer(Γ, t) = (t', A) Γ ⊢ T type ↝ T' type formation: checkType(Γ, T) = T' Γ ⊢ T type@i ↝ T' type + level: checkTypeLevel(Γ, T) = (T', i) ``` The output `t'` is the elaborated core term (fully annotated). ### 7.3 Synthesis rules (infer) **Var** ``` lookupType(Γ, i) = A ────────────────────── Γ ⊢ Var(i) ⇒ A ↝ Var(i) ``` **Ann** (annotation) ``` Γ ⊢ A type ↝ A' Â = eval(Γ.env, A') Γ ⊢ t ⇐ Â ↝ t' ────────────────────── Γ ⊢ Ann(t, A) ⇒ Â ↝ Ann(t', A') ``` **App** (application) ``` Γ ⊢ f ⇒ fTy ↝ f' whnf(fTy) = VPi(n, A, cl) Γ ⊢ u ⇐ A ↝ u' B = instantiate(cl, eval(Γ.env, u')) ────────────────────── Γ ⊢ App(f, u) ⇒ B ↝ App(f', u') ``` **CRITICAL**: `whnf(fTy)` must normalize `fTy` to weak head normal form before pattern matching. If `fTy` is a let-unfolding or a neutral that reduces further, the match will fail spuriously. In this kernel, `eval` already produces WHNF, so `whnf(v) = v` for all values. But this invariant must be maintained if the value representation changes. **Fst** (first projection) ``` Γ ⊢ t ⇒ tTy ↝ t' whnf(tTy) = VSigma(n, A, cl) ────────────────────── Γ ⊢ Fst(t) ⇒ A ↝ Fst(t') ``` **Snd** (second projection) ``` Γ ⊢ t ⇒ tTy ↝ t' whnf(tTy) = VSigma(n, A, cl) B = instantiate(cl, vFst(eval(Γ.env, t'))) ────────────────────── Γ ⊢ Snd(t) ⇒ B ↝ Snd(t') ``` **Eliminator motive checking (checkMotive).** All eliminators require a motive `P : domTy → U(k)` for some `k`. The implementation provides a shared `checkMotive` helper that handles two forms: - Lambda motives (`P = λx. body`): extend the context with `x : domTy` and verify `body` is a type via `checkType`. - Non-lambda motives: infer the type and verify it has shape `VPi(_, domTy, _ → VU(k))` for some `k`. The `k` is not fixed — motives may target any universe level, enabling **large elimination** (eliminators whose return type is a type, not a value). For example, `NatElim(λn. U(0), ...)` is valid and returns types at universe 1. **NatElim** ``` Γ ⊢ P ⇐ VPi(_, VNat, ([], U(k))) ↝ P' P̂ = eval(Γ.env, P') Γ ⊢ z ⇐ vApp(P̂, VZero) ↝ z' Γ ⊢ s ⇐ VPi(_, VNat, (Γ.env, Pi(_, App(P, Var(0)), ...))) ↝ s' -- s : Π(k : ℕ). P(k) → P(S(k)) Γ ⊢ n ⇐ VNat ↝ n' ────────────────────── Γ ⊢ NatElim(P, z, s, n) ⇒ vApp(P̂, eval(Γ.env, n')) ↝ NatElim(P', z', s', n') ``` The full typing of `s` is: ``` s : Π(k : ℕ). P(k) → P(S(k)) ``` This is checked by constructing the appropriate Pi type from `P̂`. **ListElim** ``` Γ ⊢ A type ↝ A' Â = eval(Γ.env, A') Γ ⊢ P ⇐ VPi(_, VList(Â), ([], U(k))) ↝ P' P̂ = eval(Γ.env, P') Γ ⊢ n ⇐ vApp(P̂, VNil(Â)) ↝ n' Γ ⊢ c ⇐ <Π(h:A). Π(t:List A). P(t) → P(cons h t)> ↝ c' Γ ⊢ l ⇐ VList(Â) ↝ l' ────────────────────── Γ ⊢ ListElim(A, P, n, c, l) ⇒ vApp(P̂, eval(Γ.env, l')) ↝ ListElim(A', P', n', c', l') ``` **SumElim** ``` Γ ⊢ A type ↝ A' Â = eval(Γ.env, A') Γ ⊢ B type ↝ B' B̂ = eval(Γ.env, B') Γ ⊢ P ⇐ VPi(_, VSum(Â, B̂), ([], U(k))) ↝ P' P̂ = eval(Γ.env, P') Γ ⊢ l ⇐ VPi(_, Â,
) ↝ l' Γ ⊢ r ⇐ VPi(_, B̂,
) ↝ r'
Γ ⊢ s ⇐ VSum(Â, B̂) ↝ s'
──────────────────────
Γ ⊢ SumElim(A,B,P,l,r,s) ⇒ vApp(P̂, eval(Γ.env, s'))
↝ SumElim(A',B',P',l',r',s')
```
**J** (identity elimination)
```
Γ ⊢ A type ↝ A' Â = eval(Γ.env, A')
Γ ⊢ a ⇐ Â ↝ a' â = eval(Γ.env, a')
Γ ⊢ P ⇐ <Π(y : A). Π(e : Id_A(a, y)). U(k)> ↝ P'
P̂ = eval(Γ.env, P')
Γ ⊢ pr ⇐ vApp(vApp(P̂, â), VRefl) ↝ pr'
Γ ⊢ b ⇐ Â ↝ b' b̂ = eval(Γ.env, b')
Γ ⊢ eq ⇐ VEq(Â, â, b̂) ↝ eq'
──────────────────────
Γ ⊢ J(A, a, P, pr, b, eq) ⇒ vApp(vApp(P̂, b̂), eval(Γ.env, eq'))
↝ J(A', a', P', pr', b', eq')
```
**J motive verification.** For non-lambda motives, the
implementation structurally verifies all three components:
1. Outer Pi domain matches `A` (conversion check)
2. Inner Pi domain matches `Eq(A, a, y)` (conversion check)
3. Innermost codomain is `VU(k)` for some `k`
For lambda motives (`P = λy. body`), the body is checked via
`checkMotive` against `Eq(A, a, y)`, which performs the same
verification on the inner structure. This catches motive errors
at the motive itself rather than deferring to the base case.
**Axiomatized primitive type formers** (synthesis)
Primitive type formers are synthesized directly — they infer as
inhabitants of `U(0)`:
```
──────────────────────
Γ ⊢ String ⇒ VU(0) ↝ String
──────────────────────
Γ ⊢ Int ⇒ VU(0) ↝ Int
```
(Similarly for Float, Attrs, Path, Function, Any — all at level 0.)
**Primitive literals** (synthesis)
Literals synthesize their corresponding type:
```
──────────────────────
Γ ⊢ StringLit(s) ⇒ VString ↝ StringLit(s)
──────────────────────
Γ ⊢ IntLit(n) ⇒ VInt ↝ IntLit(n)
──────────────────────
Γ ⊢ FloatLit(f) ⇒ VFloat ↝ FloatLit(f)
```
(Similarly for AttrsLit → VAttrs, PathLit → VPath,
FnLit → VFunction, AnyLit → VAny.)
**StrEq** (string equality)
```
boolV = VMu VUnit (VDescPlus (VDescRet VTt) (VDescRet VTt)) VTt
Γ ⊢ lhs ⇐ VString ↝ lhs'
Γ ⊢ rhs ⇐ VString ↝ rhs'
──────────────────────
Γ ⊢ StrEq(lhs, rhs) ⇒ boolV ↝ StrEq(lhs', rhs')
```
Both arguments are checked against `VString`. The result type is
the derived `H.bool` — `μ ⊤ (plus (retI tt) (retI tt)) tt` —
written `boolV` above. StrEq is not a dependent eliminator: it has
no motive parameter.
### 7.4 Checking rules (check)
**Lam** (lambda introduction)
```
whnf(A) = VPi(n, dom, cl)
Γ' = extend(Γ, n, dom)
cod = instantiate(cl, fresh(Γ.depth))
Γ' ⊢ t ⇐ cod ↝ t'
──────────────────────
Γ ⊢ Lam(n, _, t) ⇐ A ↝ Lam(n, quote(Γ.depth, dom), t')
```
**Pair** (pair introduction)
```
whnf(T) = VSigma(n, A, cl)
Γ ⊢ a ⇐ A ↝ a'
B = instantiate(cl, eval(Γ.env, a'))
Γ ⊢ b ⇐ B ↝ b'
──────────────────────
Γ ⊢ Pair(a, b, _) ⇐ T ↝ Pair(a', b', quote(Γ.depth, T))
```
**Zero**
```
whnf(A) = VNat
──────────────────────
Γ ⊢ Zero ⇐ A ↝ Zero
```
**Succ** (MUST trampoline for deep naturals)
```
whnf(A) = VNat
Γ ⊢ t ⇐ VNat ↝ t'
──────────────────────
Γ ⊢ Succ(t) ⇐ A ↝ Succ(t')
```
**Nil**
```
whnf(A) = VList(Â)
──────────────────────
Γ ⊢ Nil(_) ⇐ A ↝ Nil(quote(Γ.depth, Â))
```
**Cons** (MUST trampoline for deep lists)
```
whnf(A) = VList(Â)
Γ ⊢ h ⇐ Â ↝ h'
Γ ⊢ t ⇐ VList(Â) ↝ t'
──────────────────────
Γ ⊢ Cons(_, h, t) ⇐ A ↝ Cons(quote(Γ.depth, Â), h', t')
```
**Tt**
```
whnf(A) = VUnit
──────────────────────
Γ ⊢ Tt ⇐ A ↝ Tt
```
**Inl / Inr**
```
whnf(T) = VSum(A, B)
Γ ⊢ t ⇐ A ↝ t'
──────────────────────
Γ ⊢ Inl(_, _, t) ⇐ T ↝ Inl(quote(Γ.depth, A), quote(Γ.depth, B), t')
whnf(T) = VSum(A, B)
Γ ⊢ t ⇐ B ↝ t'
──────────────────────
Γ ⊢ Inr(_, _, t) ⇐ T ↝ Inr(quote(Γ.depth, A), quote(Γ.depth, B), t')
```
**Refl**
```
whnf(T) = VEq(A, a, b)
conv(Γ.depth, a, b) = true
──────────────────────
Γ ⊢ Refl ⇐ T ↝ Refl
```
If `conv(Γ.depth, a, b) = false`, this is a **type error**: the
two sides of the equation are not definitionally equal, and `refl`
cannot prove the equation. Report via effect.
**Primitive literals** (checked against their corresponding types)
```
whnf(A) = VString
──────────────────────
Γ ⊢ StringLit(s) ⇐ A ↝ StringLit(s)
whnf(A) = VInt
──────────────────────
Γ ⊢ IntLit(n) ⇐ A ↝ IntLit(n)
whnf(A) = VFloat
──────────────────────
Γ ⊢ FloatLit(f) ⇐ A ↝ FloatLit(f)
whnf(A) = VAttrs
──────────────────────
Γ ⊢ AttrsLit ⇐ A ↝ AttrsLit
whnf(A) = VPath
──────────────────────
Γ ⊢ PathLit ⇐ A ↝ PathLit
whnf(A) = VFunction
──────────────────────
Γ ⊢ FnLit ⇐ A ↝ FnLit
whnf(A) = VAny
──────────────────────
Γ ⊢ AnyLit ⇐ A ↝ AnyLit
```
**Let**
```
Γ ⊢ A type ↝ A'
 = eval(Γ.env, A')
Γ ⊢ t ⇐ Â ↝ t'
t̂ = eval(Γ.env, t')
Γ' = { env = [t̂] ++ Γ.env, types = [Â] ++ Γ.types, depth = Γ.depth + 1 }
Γ' ⊢ u ⇐ B ↝ u'
──────────────────────
Γ ⊢ Let(n, A, t, u) ⇐ B ↝ Let(n, A', t', u')
```
Note: `Let` in checking mode — the expected type `B` is for the
body `u`, not for the definition `t`.
**Sub** (mode switch: fall through to synthesis)
```
Γ ⊢ t ⇒ A ↝ t'
conv(Γ.depth, A, B) = true -- or cumulativity check
──────────────────────
Γ ⊢ t ⇐ B ↝ t'
```
This is the catch-all. If no other checking rule applies, try
synthesis and verify the inferred type matches the expected type.
### 7.5 Type formation (checkType / checkTypeLevel)
The implementation provides two variants: `checkType(Γ, T)` returns
only the elaborated term, while `checkTypeLevel(Γ, T)` returns both
the elaborated term and the universe level. `checkType` is a thin
wrapper: `checkType(Γ, T) = checkTypeLevel(Γ, T).term`. Universe
levels are computed structurally during the type formation check
(see §8.2), not by post-hoc inspection of evaluated values.
```
──────────────────────
Γ ⊢ Nat type ↝ Nat
──────────────────────
Γ ⊢ Unit type ↝ Unit
──────────────────────
Γ ⊢ String type ↝ String
(Similarly for Int, Float, Attrs, Path, Function, Any)
──────────────────────
Γ ⊢ U(i) type ↝ U(i)
Γ ⊢ A type ↝ A'
──────────────────────
Γ ⊢ List(A) type ↝ List(A')
Γ ⊢ A type ↝ A' Γ ⊢ B type ↝ B'
──────────────────────
Γ ⊢ Sum(A, B) type ↝ Sum(A', B')
Γ ⊢ A type ↝ A'
 = eval(Γ.env, A')
Γ' = extend(Γ, n, Â)
Γ' ⊢ B type ↝ B'
──────────────────────
Γ ⊢ Pi(n, A, B) type ↝ Pi(n, A', B')
Γ ⊢ A type ↝ A'
 = eval(Γ.env, A')
Γ' = extend(Γ, n, Â)
Γ' ⊢ B type ↝ B'
──────────────────────
Γ ⊢ Sigma(n, A, B) type ↝ Sigma(n, A', B')
Γ ⊢ A type ↝ A' Â = eval(Γ.env, A')
Γ ⊢ a ⇐ Â ↝ a'
Γ ⊢ b ⇐ Â ↝ b'
──────────────────────
Γ ⊢ Eq(A, a, b) type ↝ Eq(A', a', b')
-- Fallback: infer and check it's a universe
Γ ⊢ T ⇒ A ↝ T'
whnf(A) = VU(i)
──────────────────────
Γ ⊢ T type ↝ T'
```
### 7.6 Descriptions: typing rules
Descriptions classify strictly-positive datatype signatures over an
index type `I`. A description `D : Desc^k I` quantifies over at most
universe level `k`: `descArg^k S T` requires `S : U(k)`, and
`descPi^k S f D` requires `S : U(k)` and `f : S → I`. The
description's level `k` is recovered from the surrounding `μ` (via
`mu I D i`) at elaboration time; CHECK rules thread `k` through
recursive children without re-synthesising it ("homogeneity by
typing", below).
**Notation.** `Desc^k I` is the kernel value `VDesc(K, Î)` where
`K : Level` (§8.5). `μ I D i` is the kernel value `VMu(Î, D̂, î)`,
the `i`-th type in the family classified by `D`.
#### 7.6.1 desc-elim INFER (universe-polymorphic)
`descElim^K` recurses over a description at level `K`. The motive,
each case branch, and the eliminated description must agree on `K`:
```
Γ ⊢ K ⇐ Level ↝ K' K̂ = eval(Γ.env, K')
Γ ⊢ scrut ⇒ scrutTy ↝ scrut'
whnf(scrutTy) = VDesc(K_scrut, Î)
convLevel(Γ.depth, K̂, K_scrut) = true -- K conformance
Γ ⊢ P ⇐ VPi(_, VDesc(K̂, Î), ([], U(j))) ↝ P'
P̂ = eval(Γ.env, P')
Γ ⊢ onRet ⇐ Π(j:Î). P̂(descRet j) ↝ onRet'
Γ ⊢ onArg ⇐ Π(S:U(K̂)). Π(T: S → Desc^K̂ Î).
(Π(s:S). P̂(T s)) → P̂(descArg K̂ S T) ↝ onArg'
Γ ⊢ onRec ⇐ Π(j:Î). Π(D':Desc^K̂ Î). P̂(D') →
P̂(descRec j D') ↝ onRec'
Γ ⊢ onPi ⇐ Π(S:U(K̂)). Π(f: S → Î). Π(D': S → Desc^K̂ Î).
(Π(s:S). P̂(D' s)) → P̂(descPi K̂ S f D') ↝ onPi'
Γ ⊢ onPlus ⇐ Π(A:Desc^K̂ Î). Π(B:Desc^K̂ Î).
P̂(A) → P̂(B) → P̂(descPlus A B) ↝ onPlus'
──────────────────────
Γ ⊢ DescElim(K, P, onRet, onArg, onRec, onPi, onPlus, scrut)
⇒ vApp(P̂, eval(Γ.env, scrut'))
```
The `K conformance` check (`convLevel(K̂, K_scrut)`) is the
per-elimination verification that the eliminator's Level argument
matches the scrutinee description's level. Without it an eliminator
at `K = zero` could descend into a description containing
`descArg^1 (U 0) …`, and the case branches' sort binders (typed
`S : U(K̂) = U(0)`) would be ill-typed against the scrutinee's
actual `S : U(1)`.
#### 7.6.2 Homogeneity by typing (desc CHECK rules)
When CHECKing a description against `Desc^K I`, recursive sub-checks
inherit `K` directly from the surrounding type rather than
synthesising their own. The principle:
> A description's recursive children inhabit the same description type
> as the parent. Reconstructing `VDesc(K, Î)` per recursive call to
> thread the type — only to have the recursive CHECK pattern-match
> it back open — is wasted allocation.
The CHECK rules pass the surrounding type directly:
```
whnf(ty) = VDesc(K, Î)
Γ ⊢ S ⇐ VU(K) ↝ S' Ŝ = eval(Γ.env, S')
Γ ⊢ T ⇐ VPi(_, Ŝ, ([], ty)) ↝ T' -- recursive, same ty
──────────────────────
Γ ⊢ DescArg(K, S, T) ⇐ ty ↝ DescArg(K, S', T')
whnf(ty) = VDesc(K, Î)
Γ ⊢ S ⇐ VU(K) ↝ S' Ŝ = eval(Γ.env, S')
Γ ⊢ f ⇐ VPi(_, Ŝ, ([], Î)) ↝ f'
Γ ⊢ D ⇐ VPi(_, Ŝ, ([], ty)) ↝ D' -- recursive, same ty
──────────────────────
Γ ⊢ DescPi(K, S, f, D) ⇐ ty ↝ DescPi(K, S', f', D')
whnf(ty) = VDesc(K, Î)
Γ ⊢ A ⇐ ty ↝ A' -- recursive, same ty
Γ ⊢ B ⇐ ty ↝ B' -- recursive, same ty
──────────────────────
Γ ⊢ DescPlus(A, B) ⇐ ty ↝ DescPlus(A', B')
whnf(ty) = VDesc(K, Î)
Γ ⊢ j ⇐ Î ↝ j'
Γ ⊢ D ⇐ ty ↝ D' -- recursive, same ty
──────────────────────
Γ ⊢ DescRec(j, D) ⇐ ty ↝ DescRec(j', D')
whnf(ty) = VDesc(K, Î)
Γ ⊢ j ⇐ Î ↝ j'
──────────────────────
Γ ⊢ DescRet(j) ⇐ ty ↝ DescRet(j')
```
The `K` and `Î` are recovered once at the outermost CHECK, then
reused unchanged by every recursive sub-check. The rules' written
shape mirrors the implementation: `ty` (the surrounding `VDesc`
value) flows into recursive positions verbatim, and `convLevel` on
the inner description's level reduces to `convLevel(K, K)` — the
syntactic-equality fast-path of §6.6 fires.
#### 7.6.3 desc-con CHECK with checkDescAtAnyLevel
A `μ I D i` introduction `descCon D i d` checks the description `D`
against `Desc^K I`, where `K` is recovered from the surrounding
`μ`'s classifier. Because `μ` at indexed positions is checked
against `VMu(Î, D̂, î)` whose `D̂` field carries no externally-visible
level, the CHECK rule infers the level from the type-of-`D̂` at
elaboration:
```
whnf(ty) = VMu(Î, D̂, î)
checkDescAtAnyLevel(Γ, D) = (D', K)
conv(Γ.depth, eval(Γ.env, D'), D̂) = true
Γ ⊢ i ⇐ Î ↝ i'
conv(Γ.depth, eval(Γ.env, i'), î) = true
Γ ⊢ payload ⇐
```
Wraps a handler that works with child state S so it works with
parent state P, using a get/set lens. Propagates both resume and abort.
```nix
counterHandler = { param, state }: { resume = null; state = state + param; };
adapted = adapt { get = s: s.counter; set = s: c: s // { counter = c; }; } counterHandler;
# adapted now works with { counter = 0; logs = []; } state
```
## `adaptHandlers`
Adapt an entire handler set (attrset of handlers) to a different state context.
Applies the same get/set lens to every handler in the set.
```nix
stateHandlers = {
get = { param, state }: { value = state; inherit state; };
put = { param, state }: { value = null; state = param; };
};
adapted = adaptHandlers { get = s: s.data; set = s: d: s // { data = d; }; } stateHandlers;
```
### Binds
Idiomatic Nix bind helpers
## `bindAttrs`
Like a bind-chain but operates over named attrset of required-effects.
```nix
bind.attrs { foo = 99; bar = pure 22; baz = asks (env: env.baz); }
```
Values that are non-effects become send params: `send "foo" 99`.
Pass the `optionalArg` sentinel to mark a key as optional: bindAttrs
probes via `has-handler` first and omits the key when no handler is
installed (so a Nix function's default value can take over).
Result has same attr-keys with corresponding effect result.
See also: `bind.comp`, `bind.fn` for which this is the foundation.
# NOTE: Ordering of chained effects.
Since an attrSet has no order, this function chains effects in
same order as `builtins.attrNames` (alphabetical). If you need
an special order for computations that might be order senstive,
specify a `__sort = names => names` function.
## `bindComp`
Turns a Nix effectful function into an effect chain via bindAttrs.
```nix
bindComp { bar = pure 22; } ({ foo, bar }: pure (foo * bar))
```
The function sees bar as the result of `pure 22` and `foo` as the
result of `send "foo" false` -- false comes directly from using
`lib.functionArgs f`, the handler can know if "foo" is optional in f.
Optional args (those with defaults in the Nix function) are probed
via has-handler before sending. If no handler exists, the arg is
skipped and the Nix default kicks in.
This works by using `bindAttrs` on the intersection of function args
and attrs.
## `bindFn`
Like bindComp but works on normal Nix functions and turns
its result into a pure-effect.
```nix
bindFn { bar = pure 22; } ({ foo, bar }: foo * bar)
```
### Build
## `materialize`
Materialize a validated BuildPlan into a derivation.
Converts the eval-time plan (validated by fx.build.plan) into a
pkgs.runCommand derivation. Sources are copied into a working
directory, per-step environment variables are scoped, and steps
execute sequentially under set -euo pipefail.
```
materialize : { pkgs, plan, native? } -> derivation
```
The plan argument is the `.plan` field from fx.build.plan output.
Shell generation helpers (mkStepScript, mkSourceSetup, mkBuildScript)
are pure functions tested inline.
## `plan`
Validate and process build steps into a BuildPlan.
Runs an eval-time pipeline that validates each step against
BuildStep, filters steps by `when` predicates using reader context,
and collects all errors without throwing.
```
plan : { name, steps, sources?, context? } -> { plan, errors, warnings, typeErrors }
```
## `types`
Build types for effects-powered builders.
BuildStep and BuildPlan describe build pipelines at the type level,
enabling validation before materialization into derivations.
### Comp
Computation ADT: introduction and elimination forms for Pure | Impure.
## `impure`
Create a suspended computation (OpCall constructor). Takes an effect and a continuation queue.
## `isComp`
Test whether a value is a computation.
## `isPure`
Test whether a computation is Pure. For hot-path conditionals where match would allocate.
## `match`
Eliminate a computation by cases.
```
match comp { pure = a: ...; impure = effect: queue: ...; }
```
Every function that consumes a Computation should go through
match or isPure — never inspect _tag directly.
## `pure`
Lift a value into a pure computation (Return constructor).
### Diag
The `diag` namespace provides typed diagnostic Errors and
structured Hints for the type-checker and runtime contracts.
## error
Diagnostic Error ADT.
An Error has a Layer (Kernel | Generic), a Detail record whose fields
are all optional, a short msg, an optional hint, and a list of
children. A leaf has `children = []`; a branch has a non-empty
children list whose entries are `{ position, error }` pairs. Sibling
failures produce many children; a chained descent produces one
child; a leaf has none.
Constructors:
mkKernelError { position?, rule, msg, expected?, got?, ctx_depth?, hint? }
mkGenericError { type?, context?, value, desc?, index?, guard?, msg, hint? }
Combinators:
nestUnder : Position -> Error -> Error
addChild : Position -> Error -> Error -> Error
Layer constants: Kernel, Generic. Predicates: isError, isLayer.
Equality: eq (structural).
Pure data. No dependencies on kernel, trampoline, effects, tc, or
types modules.
## positions
Shared diagnostic alphabet. Pure data.
A Position names the blame location in a structural descent through
a Desc or through raw MLTT structure (Π / Σ / Ann / μ / App). The
alphabet is description-centric: names such as `DArgSort`, `DPlusL`,
and `PiDom` identify sub-positions by their meaning in the structure,
not by the code path that happens to visit them.
Two kinds of consumer:
- A kernel enrichment layer that wraps rule delegations, emitting
a child error tagged with the `Position` of the sub-call that
failed.
- A value-level validator (record / list / variant field walkers)
that emits `Field` / `Elem` / `Tag` positions from its per-
component blame traversal.
Both consumers produce `Error` trees whose children are keyed by
`Position`, allowing errors from either source to compose into one
tree.
## pretty
Pretty-printing for diagnostic Errors.
Exports:
pathSegments : Error -> [String]
pathString : Error -> String
oneLine : Error -> String
multiLine : Error -> String
Chain walkers recurse directly up to 500
frames, then fall through to a `builtins.genericClosure` slow
path that WHNF-forces the next node at each step.
Pure data -> string; no effects.
## hints
Hint resolver for diagnostic Errors.
Exports:
resolve : Error -> Hint | null
classify : Error -> String
hints : { -> Handler