From 4cb53a0a9f90dd4a49bbf4ee0623c8b895f1188c Mon Sep 17 00:00:00 2001
From: Joseph Ferano
Date: Mon, 21 Sep 2026 12:54:13 +0700
Subject: [PATCH] What the two function types changed in the documents
The four refusals the function-value lane wrote down are now three and a
half: capture is a feature, the escape is the refusal in its place, and a
struct field or a global of function type is refused for the same zero as
before with a sharper reason behind it. The handler note is amended
rather than deleted -- its reading was right, and saying so is worth more
than a clean paragraph.
The convention section carries both rulings, the name and every name it
beat, the warning that CFn is not C interop today, the four reasons to
reach for it, what was measured rather than asserted about an ordinary
defn, and the wasm32 finding that killed the first design -- being
exactly typed is checkable by a verifier and the alternative was an
argument from a calling convention.
FIX.org carries the rulings verbatim, what the escaping lane inherits and
changes, the thunk's environment holding a code pointer rather than a GC
object, and two things recorded and not built: a CFn under a future
--no-conditions reaching C's exact convention, and CFn in a struct or a
fixed array, which ZII refuses and capture has nothing to do with.
---
FIX.org | 89 ++++++++++++++
docs/BUILT.md | 321 +++++++++++++++++++++++++++++++++++++++++++++----
web/index.html | 3 +-
3 files changed, 390 insertions(+), 23 deletions(-)
diff --git a/FIX.org b/FIX.org
index d0b9333f..ca4acdca 100644
--- a/FIX.org
+++ b/FIX.org
@@ -6079,3 +6079,92 @@ table names. Harmless today only because check.ml refuses a dyn field in a
struct — the stopgap item 2 of the M2 queue lifts. Whoever lifts it has to
root this buffer, or a collection that runs inside the construction will not
see what has been built so far.
+* Closures, 2026-09-21 — two rulings, and what this lane built
+Two rulings, both in the author's words.
+
+The first, on what to build:
+
+ "do both, capture by value and handle escaping closures, allocated on the
+ GC side"
+
+This lane is the first half: capture by value into a stack environment,
+spec-memory.md's case 2, non-escaping only. The second half — an environment
+the collector allocates, and with it the escaping closure — is a separate lane,
+and every refusal this one prints names it.
+
+The second, on the calling convention, after the first design put an
+environment parameter on every Flan signature:
+
+ "while it's dyn first, static side should never have to pay the price for
+ the existence of the dyn side... if you fully opt out, for instance, using
+ --no-gc flag, then we should be operating under Odin/C semantics and never
+ paying any runtime costs"
+
+So there are two function types, Rust's and Swift's shape:
+
+ (Fn [T ...] R) captures; {code, env}; the common case, short name
+ (CFn [T ...] R) the bare address; one word; cannot capture
+
+** The name, ruled 2026-09-21
+CFn, because the C carries information rather than being decoration: a value
+with no environment is the only kind that could ever cross to C, and under the
+--no-conditions direction below it becomes literally a C function pointer. The
+name points at what the type is and at where it is going.
+
+Rejected: Closure, too long. Proc, because "procedure" is a word we disagree
+with Odin about. Fun and Func, because beside Fn they differ only in length,
+so nothing tells a reader which one captures. Fnptr, as ugly.
+
+The thing to be careful about, and it is written into the crossable refusal so
+a reader meets it where they would otherwise be misled: CFn is not the type
+for C interop *today*. A declare cannot take a function type at all, because a
+Flan signature ends with the transfer channel.
+
+** Nobody needs CFn, and one of the four reasons is the common one
+An Fn accepts everything a CFn does, so the narrow one is always reached for
+on purpose:
+
+1. handing a function to C — later, per the item below;
+2. a table of bare addresses;
+3. forbidding capture at a boundary, where the type is the statement;
+4. performance, which is likeliest in practice. A *named* function passed to
+ an Fn parameter goes through the widening thunk and pays an indirect hop
+ per call; a CFn parameter is a direct call. (map-in-place s double) is the
+ example. The prelude's four stay Fn on purpose — a capturing predicate is
+ what people want — so this is the cost someone would opt out of by writing
+ their own signature, not one the prelude should have avoided.
+
+** What the escaping lane inherits, and what it changes
+Unchanged by it: a (Fn ...) is two words; the environment is the last
+parameter; it is declared by exactly the bodies an Fn value can reach — a
+lifted literal in an Fn position, every handler clause, and the widening
+thunks. An ordinary defn declares none and is byte-for-byte what it was.
+
+Changed by it: where the environment points. It is a Make into a frame slot
+today and becomes a collector allocation; Check.escape_check goes away with it,
+and with it the refusals on returning, storing, pointing at and pushing a
+capturing value.
+
+One thing for the collector to know now rather than discover: a widening
+thunk's environment holds a *code pointer*, not a frame address and not a GC
+object. When env becomes collector-allocated the marker needs a way not to
+follow a thunk's.
+
+Also still refused and belonging to the second half: capturing a dyn. The
+struct dyn field is no longer refused — per-type descriptors landed — but a
+synthesised environment has no descriptor, so the refusal stands until it does.
+
+** Recorded, not built: CFn and C's convention
+A CFn is one word and is the right shape for a C callback, and it is still
+not one: a Flan function's signature ends with the transfer channel and a C
+caller knows nothing about one. Under a future --no-conditions flag a CFn
+signature could drop the channel and reach C's exact convention, which is the
+direction the author is interested in. The refusal in [crossable] names it.
+
+** Also worth an item: CFn in a struct or a fixed array
+no_zeroed_fn refuses a function value in any position ZII would conjure one,
+and it refuses a CFn for the same reason it refuses an Fn: a zeroed function
+value is a null pointer, which is the one zero that is not a value the type can
+have. But "a table of function pointers" is exactly what CFn is for, and that
+objection is about ZII rather than about capture — an (Option (CFn ...)) field
+is already legal and is the shape that works. Its own item.
diff --git a/docs/BUILT.md b/docs/BUILT.md
index 56c23650..bb08a35a 100644
--- a/docs/BUILT.md
+++ b/docs/BUILT.md
@@ -1861,15 +1861,17 @@ stack does *not* observe a redefinition of its own clause; the next entry to the
Which gives the two refusals, both by the house rule rather than by accident:
-- **A handler cannot see the establishing function's locals.** That is a closure with an explicit environment, so a
-reference to one is refused *for that reason* rather than reported as an unknown name. Globals and the condition are in
-scope, which is what the accumulation case needs.
+- **A handler cannot see the establishing function's locals.** *Superseded: it can, by value — see "Capture by value"
+below. What is still refused is a `set` into one, because a captured name is a copy.* The original note read: that is a
+closure with an explicit environment, so a reference to one is refused *for that reason* rather than reported as an
+unknown name; globals and the condition are in scope, which is what the accumulation case needs.
-What it needs is narrower than it looks, and worth getting right before anyone schedules it: a handler frame does not
-outlive the function that established it, so this is spec-memory.md's **case 2** — a non-escaping `fn` capturing by
-value into a stack environment — and *not* the escaping closure that plan.org's open decision #5 defers until a concrete
-use case. Case 2 is settled, and #5 says in as many words that without it "conditions are not worth building". So the
-biggest usability limit in conditions is not behind the thing that was just deferred.
+The reading that turned out to be right: a handler frame does not outlive the function that established it, so this is
+spec-memory.md's **case 2** — a non-escaping `fn` capturing by value into a stack environment — and *not* the escaping
+closure that plan.org's open decision #5 defers until a concrete use case. Case 2 was settled, was built, and brought
+the handler along with it for one struct field and one argument. #5 says in as many words that without it "conditions
+are not worth building", and the biggest usability limit in conditions was indeed never behind the thing that was
+deferred.
- **`return` inside a `handler-bind` body is refused.** The frames are popped on the way out and an early exit would
leave them on the stack pointing into a function that has gone. Same shape as `defer` inside a block.
@@ -2702,9 +2704,11 @@ spec together; neither is a cleanup, and until one is taken the word is carried
`(Map K V)` is built — see below. What is left: `drop` and with it the transitive move-only rule, recursive teardown,
and the refusal to construct a drop-carrying container against an allocator without `can-free`; `(Result T E)` and
`try`; generics; the macro expander.
-And the **accumulation pattern** — `(fn [c] (push errors c) ...)` over an enclosing Vec — which `Vec` does not buy:
-capture does not exist at all, and the spec's captured-`Vec`-by-pointer rule has never had to exist because every
-capturable type today is a value type. It is its own item and should be planned as one.
+And the **accumulation pattern** — `(fn [c] (push errors c) ...)` over an enclosing Vec — which `Vec` does not buy.
+Capture exists now (see "Capture by value" below) and this is still not it: capture is by *value*, so the `fn` would
+push into its own copy of the header and leave the enclosing one at the length it had. The spec's
+captured-`Vec`-by-pointer rule is exactly the thing that has still never had to exist. It is its own item and should
+be planned as one.
## `(Map K V)`, which is Odin's map
@@ -3817,9 +3821,10 @@ cannot share a name** — which is exactly what makes the bare name safe to read
`double` it could have meant instead, so the sharp quote would be punctuation answering a question the language does
not ask.
-A `Types.Fn` is one pointer. There is no environment beside it, so the type resolves to `ptr` and lays out as eight
-bytes, and a call through one is byte-for-byte the call a name would have produced — a Flan function's emitted
-signature is its parameters followed by the transfer channel whether it was reached by name or by pointer. That is
+A `Types.Fn` was one pointer when this lane landed. It is two words since capture, and the one-word version has a
+name of its own now — `(CFn [T ...] R)`; see "Capture by value" below. A call through either is the call a name
+would have produced, with the environment appended for a `Fn`: a Flan function's emitted signature is its
+parameters, then the transfer channel, and then the environment on the bodies that can be reached that way. That is
why a handler established across a `fold` still catches a signal raised by the function the fold was handed:
`programs/fn-values.flan` does exactly that, and it is the case that would fail if an indirect call skipped the
guard.
@@ -3840,11 +3845,8 @@ implemented.
**Refused, each with its own reason and its own program:**
-- **Capture does not exist** (`fn-capture.flan`). An `fn` is lifted into a function of its own and handed nothing but
- its parameters; a reference to a local of the enclosing function is refused by name. This is the same refusal a
- handler clause has always carried, and the two now share one message with the construct's name in it.
- `spec-memory.md`'s capture cases, and **escaping closures with them, stay deferred** — deliberately, and this is
- what keeps a function value a bare code address that cannot outlive anything.
+- **Capture does not exist.** *Superseded — see "Capture by value" below. It exists, the program that was this
+ refusal's witness now runs, and what is refused in its place is the **escape**.*
- **An `fn` with nothing to say what it takes** (`fn-no-type.flan`), above.
- **A position that would zero one** (`fn-in-struct.flan`): a struct field, a global, a fixed array's element,
`(zeroed)`. ZII fills an omitted field with all-bytes-zero, and **a zeroed function value is a null pointer, which
@@ -3855,9 +3857,276 @@ implemented.
past its length and `flan_map_alloc` zeroes only the hash run, so neither conjures an element nobody pushed or
put. A function value as a map *key* is refused already, by `Types.keyable` — hashing an address is a different
operation from hashing what it points at.
-- **A foreign function's address** (`fn-extern.flan`). A Flan function's signature ends with the transfer channel and
- a C one does not, and an aggregate crossing the boundary is flattened by a generated shim the raw symbol knows
- nothing about. Wrap it in a `defn` and pass that.
+- **A foreign function's address** (`fn-extern.flan`). A Flan function's signature ends with the environment and the
+ transfer channel and a C one does not, and an aggregate crossing the boundary is flattened by a generated shim the
+ raw symbol knows nothing about. Wrap it in a `defn` and pass that. Capture widened this gap rather than closing
+ it: a Flan function value is now two words and a C symbol is one.
+
+## Capture by value, and what "non-escaping" had to mean
+
+`spec-memory.md`'s **case 2**, which the section above listed as the headline refusal and which is now the headline
+feature. This compiles:
+
+```
+(let [bonus 10]
+ (apply2 (fn [x] (+ x bonus)) 5))
+```
+
+`bonus` is **copied** into an environment on the enclosing function's frame at the instant the `fn` value is made,
+and the lifted body reads the copy. Not a reference: `fn-capture.flan` changes the local through a pointer *after*
+the value exists and *before* it is called, and the `fn` still answers with the old one. That test is the whole
+claim, and it is the one no evaluation order can fake.
+
+### Two function types, because the static side does not pay for the dynamic side
+
+```
+(Fn [i32] i32) ; captures; {code, env}; the common case
+(CFn [i32] i32) ; the bare code address; one word; cannot capture
+```
+
+The forcing constraint first. A callee that takes a `(Fn [i32] i32)` and calls it knows nothing about where the
+value came from — `fold` is handed a value and calls it — so **the environment has to travel with the value** or
+there is nowhere to put it. A `Fn` is therefore `{code, env}`: sixteen bytes, classified as an aggregate in both
+backends exactly as a slice is.
+
+The first design put an environment parameter on **every** Flan signature, uniform for the reason the transfer
+channel is uniform. The author ruled against it, and the ruling is the principle rather than the case: *"while it's
+dyn first, static side should never have to pay the price for the existence of the dyn side… if you fully opt out,
+for instance, using `--no-gc`, then we should be operating under Odin/C semantics and never paying any runtime
+costs."* A uniform environment taxes every function in every program for a feature most of them never use.
+
+So there are two types, Rust's and Swift's shape. `Fn` keeps the short name because it is what almost every
+higher-order signature wants; `CFn` is the narrow one.
+
+**The `C` is information and not decoration**, which is what settled the name. A value with no environment is the
+only kind that could ever cross to C, and under the `--no-conditions` direction FIX.org records — where a signature
+that cannot transfer drops the transfer channel too — one becomes literally a C function pointer. The name points at
+what the type *is* and at where it is going. `Closure` was rejected as too long; `Proc` because "procedure" is a
+word this language disagrees with Odin about; `Fun` and `Func` because beside `Fn` they differ only in length, so
+nothing tells a reader which one captures; `Fnptr` as ugly.
+
+**It is not a capability today, and the diagnostic says so.** A `declare` cannot take a function type at all — a
+Flan signature ends with the transfer channel and a C caller knows nothing about one — so anyone reaching for `CFn`
+straight after writing a `declare-c` is reaching too early. `crossable`'s refusal names that in as many words: *"the
+C in CFn is about having no environment, which is what a C function pointer would need, and not about crossing
+today."*
+
+**And nobody ever needs it.** `Fn` accepts everything a `CFn` does, so the narrow one is reached for on purpose, for
+one of four reasons:
+
+1. handing a function to C — later, as above;
+2. a table of bare addresses;
+3. forbidding capture at a boundary, where the type is the statement;
+4. **performance, which is likeliest in practice.** A *named* function passed to an `Fn` parameter goes through the
+ widening thunk and pays an indirect hop per call; a `CFn` parameter is a direct call. `(map-in-place s double)`
+ is the example — and the prelude's four stay `Fn`, because a capturing predicate is exactly what people want.
+
+**An ordinary `defn` keeps its exact signature.** Verified rather than asserted: the LLVM for `calc-me.flan` and
+fourteen corpus programs was diffed against the same compiler without this lane. Exactly three kinds of difference
+appear, and no fourth:
+
+- two type declarations in the preamble — `%fnv`, and `%handler`'s new `env` field;
+- the prelude's `sort-by-slice-u8`, whose *parameter* is now `%fnv` because it is declared `(Fn [$t $t] bool)` and
+ pays two words for the value it asked for; and the lifted literal it is handed, which gains a trailing `ptr %env`
+ because an `Fn` value can reach it;
+- in a program with a `handler-bind`, its clauses gain the same trailing `ptr %env` — every clause declares one,
+ see below.
+
+**No ordinary `defn` gained a parameter, in any program.** `flan_rt.c`'s hash and equality typedefs are untouched;
+`main`, the macro thunk, the startup call and the reload thunk emit the calls they always emitted.
+
+### The environment is the last argument, on exactly the bodies an `Fn` can reach
+
+The environment is the last parameter, after the transfer channel, and it is declared by **exactly the bodies a
+`(Fn ...)` value can reach**: a lifted `fn` literal written into an `Fn` position, capturing or not; every handler
+clause, because `flan_signal` passes one to whichever clause matched and cannot know which of them captured; and the
+widening thunks below, which exist to read it. Nothing else declares it, which is where an ordinary `defn` keeps
+costing nothing.
+
+So **every indirect call is exactly typed** and nowhere does a caller pass an argument the callee did not declare.
+
+That was not the first attempt. The first put the environment last and let a body that never asked for one simply
+ignore the register it arrived in — legal under SysV, where argument N is classified from arguments 1..N alone, and
+exactly **Swift's thin-vs-thick convention**, where a thin function converts to a thick one by pairing with a null
+context the thin body ignores. It works on x86-64 and it is dead on **wasm32**, where `call_indirect` compares the
+signature at the call site: a spare argument is a trap, not a register nobody reads. `test_web`'s two cases and the
+headless `sand` build failed with *"null function or function signature mismatch"*, and the honest reading is that
+being exactly typed is checkable by a verifier rather than argued from a calling convention, which is the better
+property to have wanted.
+
+**So there is one adapter, and it is per *signature* rather than per function.** `CFn` → `Fn` is `Tast.Thicken`,
+and the pair it builds is `{thunk, the address}`: the thunk's code, with the bare address stored where an
+environment would be. The thunk — `thick/`, minted and memoised by the checker the way
+`struct_key_pair` mints a map's hash and equality pair — declares the environment, reads the address back out of it,
+and calls through it. One small function per distinct shape a program widens, not per function it widens, and it
+handles the dynamic case (a `CFn`-typed local or parameter widened at a call) with the same mechanism as the
+static one.
+
+**What it costs, plainly.** A *name* handed to an `Fn`-typed parameter now pays one indirect hop per call:
+`(map-in-place s double)` goes through the thunk per element where it used to reach `double` directly. A literal
+pays nothing — capturing or not, it is compiled to take an environment and needs no thunk. The escape hatch is
+writing `CFn` in the signature, which is what the type is for; the prelude's `map`, `filter`, `reduce` and
+`sort-by` correctly stay `Fn`, because a capturing comparator is exactly what people want, so the common
+named-function case does pay. That is the one real price of two types, and it buys every function in every program
+not paying for an environment it never has.
+
+A redefinition module carries its own copy of every thunk, hidden. A module that widens a name refers to one, and
+the host has no cell for it to be reached through — the same shape of bug as the `Fnval` cell below, found the same
+way and closed before it shipped. `flan reload` with a body that widens a name builds on both backends.
+
+The reverse coercion does not exist — there is nowhere for an environment to go — and is refused by the ordinary
+type message, which names both spellings (`fn-cfn-narrow.flan`). A capturing literal written into a `CFn` position
+is refused by name, with what it captured and the fix in the sentence (`fn-cfn-captures.flan`).
+
+One trap worth naming, because it is where the map would have broken: `FnAddr` is asked for by unrelated readers. A
+`Fn`-typed one is two words; a `CFn`-typed or `Alloc`-typed one is a bare address — the second is what the map's
+hash and equality pair and a handler frame's clause are, fields of structs the runtime declares, and they must stay
+one word. **The node's type is what discriminates**, in both backends.
+
+A handler clause is the one place the runtime does the passing, so `flan_handler` grew an `env` field and
+`flan_signal` calls `h->fn(condition, xfer, h->env)` — the same trailing position, and a clause that captured
+nothing declares nothing and is unaffected.
+
+### The environment is a struct the checker synthesised
+
+One field per captured name, in first-reference order, registered in the same table a `defstruct` goes in — so both
+backends lay it out with the calculator they already have and neither learns a new shape. Its name is the lifted
+function's (`env/fn/OWNER/N`), which is unique and stable for the reason that name is.
+
+Two ends, and the copy is at the near one. In the *enclosing* frame, a slot holding the struct, filled with a `Make`
+of the outer locals: that store is the copy, and it happens where the value is made. In the *lifted* frame, a slot
+holding the pointer and a `Let` around the whole body reading each field back into the named slot the body was
+checked against — once, at entry, so nothing downstream has to know an environment exists.
+
+Both of the compiler's new slots are **nameless**, which is how the break loop is told to hide them. That is a
+deliberate call, not an omission: what a reader wants at a stop is the captured *copies*, and those are named slots
+holding the values under the names the source gave them. An `env` pointer and a struct of bytes would be two rows of
+noise above them.
+
+A redefinition that changes which locals an `fn` names changes an environment's layout, and `Session`'s layout guard
+**exempts** these. The guard is about values the running program is holding; an environment can be in exactly one
+place, a slot of the frame the literal was written in, written by the same module that reads it on every entry. A
+restart for editing a capture list would take the dev loop away from the feature it was built for.
+
+### Escape, which is what makes "case 2" a bounded claim
+
+A value carrying an environment may be **called, passed down, and held in a `let`**. It may not be **returned,
+stored, pointed at, or pushed into a container**. The check runs over the typed IR of every function the program
+ends up with — including the lifted ones, so an `fn` inside an `fn` needs no special case — and classifies
+function-typed values as *suspect* or clean:
+
+- suspect: a capturing literal (`Tast.Closure`, the only node that makes one); a **parameter** of type `Fn`, in
+ every function; an `Fn` read back out of a struct, a case or a pointer; a local bound to any of those,
+ transitively; a branch or a valued form whose value is one.
+- clean: the address of a name, the result of any call, and **everything of type `CFn`** — the last for free,
+ because a `CFn` has no environment to dangle and the type says so. The second follows from the first refusal,
+ which is what stops a function from returning a suspect at all.
+
+The two types made this pass narrower rather than wider, which is the point of having them: a signature that says
+`CFn` has already promised what the analysis would otherwise have to prove, and nothing written against one is
+ever examined.
+
+Two of those arms are there because leaving them out is unsound rather than merely conservative, and each has a
+program. **A function value read out of an environment** (`fn-escape-copy.flan`): a lifted body holds *copies* of
+what it captured, read back with `Field(Deref env, i)`, so a copy of a captured function value carries whatever
+environment the original did. Treat it as clean and the lifted body can return it, the return arrives at the outer
+caller as an ordinary call result, and the whole "a call result is clean" rule has been walked around from inside.
+**A valued form's tail** (`fn-escape-handled.flan`): `handler-bind`, `with-allocator` and `restart-case` are
+expressions whose value is their body's — and a `restart-case`'s is a clause's too — so each is a way for a suspect
+to be a function's answer that a check looking only at `return` and at the last form of a block would step over.
+
+**The parameter rule is the whole answer to the hard case.** A capturing `fn` passed to a function that stores it is
+caught *inside that function*: its parameter is suspect there and the store is refused where it is written. So no
+call can leak what its caller passed, and no caller has to be analysed. What it costs is real:
+`(defn keep [f (Fn [] i32)] (Fn [] i32) f)` is refused although it is harmless, and so is holding a parameter of
+function type in a `Vec` that never leaves the frame. `fn-escape-param.flan` is that refusal, written down as a
+refusal of something that would sometimes have been fine.
+
+Refusing `Addr` of a suspect matters more than it looks: without it, `deref` of a `(Ptr (Fn ...))` launders a
+suspect into a clean value and the return refusal has been walked around. Treating the `deref` itself as suspect is
+the other half of that door, and it is free: nothing a `(Ptr (Fn ...))` can point at is anywhere but a frame, since
+a global and a struct field of function type are both refused already.
+
+Name resolution inside a lifted body now asks the enclosing function's locals **before** the globals, which is a
+deliberate tightening: inside the enclosing function a local shadows a global of the same name, so a body lifted out
+of it must mean the same thing. The old order was an accident of where the refusal sat.
+
+Every one of these messages names **case 3** — the escaping closure, with an environment the collector owns —
+because "this cannot be done" and "this cannot be done yet" are different sentences and the second is the true one.
+Five programs: `fn-escape-return.flan`, `fn-escape-param.flan`, `fn-escape-store.flan`, `fn-escape-vec.flan`, and
+`fn-capture-set.flan`.
+
+### What may be captured
+
+Anything but a **dyn**. A scalar, a struct and a fixed array copy whole. A string, a slice and a `Vec` or `Map`
+header copy as their words, aliasing whatever they pointed at — which is exactly right while the value cannot
+outlive the frame that owns the storage, and is exactly what would break under escape. A function value copies as a
+function value, environment included; capturing one into another `fn`'s environment is the one place a suspect may
+be written into an aggregate, and it is sound because the outer literal is itself suspect, so the pair of
+environments lives and dies with one frame.
+
+A **dyn is refused**, for the reason a struct field of dyn already is (`A struct cannot hold a dyn field the
+collector would never find`): the collector's roots are frames, and nothing pushes the fields of a synthesised
+environment. A copy in there would be a live value reachable only through memory the marker never walks. Milestone
+2's per-type descriptors lift it, alongside the condition payload's and the struct field's — and case 3's
+collector-allocated environment is where it belongs anyway. `fn-capture-dyn.flan`.
+
+### Handlers, which get this for free and have no case 3 to wait for
+
+`docs/BUILT.md` said a handler clause cannot see the establishing function's locals, refused for the same reason,
+and that this is also case 2. **It is, and it now can.** A handler frame is popped by the body that pushed it and
+nothing in the language can name one, so the establishing frame is alive whenever the clause runs — there is no
+escaping case here to leave over. The only compiler change beyond the shared machinery is one field on
+`flan_handler` and one argument in `flan_signal`, which the uniform signature required anyway.
+
+What is still refused is a **store** into a captured name, in a clause as in an `fn`: the clause holds a copy, and
+writing to it would change the copy and leave the local as it was. Scope is asked first, so a body's own `let`
+shadowing a name the enclosing function also has is an ordinary local and an ordinary store — the refusal is about a
+captured copy and not about a spelling.
+
+A **field** of a captured struct is a different matter and is deliberately left alone: `(set (.x p) 9)` inside an
+`fn` writes the copy and leaves the enclosing `p` as it was — which is exactly what `(set (.x p) 9)` inside a
+function whose `p` is a *parameter* already does, and has always done. Capture takes a copy the way a call takes
+one, so the two agree; refusing here would make the `fn` stricter than the `defn` it was written in for no reason
+anyone could state. So §1's accumulation case still accumulates into
+a global — and now with whatever the establishing function knew readable beside it, which is the half that was
+missing. `fn-capture.flan`'s `handles` reads a captured budget in the clause.
+
+Capturing the establishing frame **by reference** would make the accumulation case work directly and would be sound
+here, uniquely — but it is a different feature from case 2, it would fork what "capture" means between the two
+constructs, and it is not what was asked for. Named, not done.
+
+### Recursion, nesting and loops
+
+Capture is **transitive**: an `fn` inside an `fn` naming a local of the function both were written in makes the
+middle one capture it and the inner one copy the middle one's copy. That is the same value, because every copy on
+the way was taken at the moment its own value was made and those moments are nested. `check`'s context carries a
+`parent` for exactly this, and it is safe to reach into precisely because a lifted body is checked at the point it
+is written, with its parent paused there.
+
+An `fn` written **inside a loop** stores into the same environment slot each time round, so what it sees is the
+value on its own iteration and not the last one. `fn-capture.flan` sums `0 + 1 + 2 + 3` through a fresh `fn` per
+iteration to say so. The trap this could have been — a closure `set` into a local bound outside the loop and called
+after it, seeing the last iteration's copies — cannot be written: a captured value cannot be `set` anywhere, and a
+`let` binding scopes to the iteration.
+
+### What each backend cost
+
+Very little, which was the point of putting the environment in a frame slot and passing it as an ordinary argument
+at the one call that needs it.
+
+`emit.ml`: a `%fnv` type and a 16-byte layout for `Fn`, `ptr` and eight for `CFn`; an `insertvalue` pair where a
+symbol used to stand alone; two `extractvalue`s at a call through an `Fn`; one appended operand on that call and on
+no other; one store in the prologue of a body that declared an environment. The four hand-written glue sites — the
+startup call, `main`, the macro thunk and the reload thunk — are **unchanged**.
+
+`x86.ml`: `Fn` becomes an aggregate and `CFn` stays a scalar; one appended argument after the channel on an `Fn`
+call; an optional `incoming` slot; the 16-byte value split at a `CallPtr`. The three hand-built entries are
+unchanged.
+
+`flan_rt.c`: one field on `flan_handler` and one argument on the clause typedef, both trailing. The hash and
+equality typedefs and their five call sites are **unchanged** — a hasher is reached from inside that file and never
+through a function value, so it declares no environment and is handed none.
### `Fnval`, and the one thing a dev build cannot do
@@ -3871,6 +4140,14 @@ What that does *not* give: a value taken *before* a redefinition and called afte
address is in a slot there is nothing left to re-resolve, and the honest fix is a trampoline per function, which is a
cost every program would pay for a case no one has hit. Named here rather than papered over.
+**An `fn` literal was asking for `Fnval` and should never have been**, which the capture lane found as a bug rather
+than as a design question: `flan reload` on any function containing an `fn` literal failed at `llc` with
+`use of undefined value '@flan.cell.fn/OWNER/N'`. A lifted body has no name anyone can type and no way to be
+redefined on its own — it is reached by address from the body it was written in, and a redefinition of that body
+carries its own copy — so the cell could never hold anything but the symbol, and a redefinition module had no reason
+to declare one. It takes `Flanfn` now, which is the choice a handler clause has always made and for the same reason.
+`Fnval` remains what it was for: a `defn`'s *name* in value position.
+
The two lifted-function name sequences are counted **per kind** — `fn/OWNER/N` and `handler/OWNER/N/TYPE` —
rather than off one list. Sharing a counter would rename every `fn` in a function the moment a `handler-bind` was
added above one, which is a rename for a body that did not change, in exactly the names a redefinition module emits.
diff --git a/web/index.html b/web/index.html
index d48628ac..be27bf4d 100644
--- a/web/index.html
+++ b/web/index.html
@@ -519,7 +519,8 @@ notation reads as exactly one data item.
(Handle T) | a reference into a pool that reports a dead referent | index and generation packed into an i64 |
(Ptr T) | raw pointer | a pointer |
(Option T) | Some / None | tag byte + T |
-(Fn [T ...] R) | a function value | a pointer |
+(Fn [T ...] R) | a function value, which may have captured | a code address and an environment pointer |
+(CFn [T ...] R) | a function value that cannot capture — the C is what a C function pointer would need, not a way to reach C today | a pointer |
Allocator | an opaque builtin: a proc, its data and a capability set | a pointer to that |
$t | a type variable — see generics | whatever it is instantiated at |
| a struct | value type | fields in declaration order |