The first is CLHS 9.1.4.1: a clause that signalled the condition it handles used to re-enter itself until the stack ran out. The stale list now names a lifted clause by the function it is written in, and BUILT.md's cost line is measured against master.
The dropped (agent/start ...) note named the wrong cause rather than being unreachable: an over-long socket path fails the agent's bind while it is linked. That case is now refused before anything is built, and the comments, the TODO entry and the fixture say so.
A dev cell carries its body's signature word beside the body, every call through a cell (and every function value taken from one) compares it with the word the site was compiled for, and the session lists the stale callers by file and line on the reply. Both backends, both installers; release builds have neither the word nor the compare.
FIX.org, NEXT.md, DISCUSS.org, docs/DISCUSS.md and the session handoff at the
root are one TODO.org now: 293 entries under seven subsystem headings, each
carrying an org keyword that says where it stands. A DONE entry is a few lines
saying what was decided and what that rules out; the reasoning that would not
compress — the embedding spike and the four reports the hand-written x86
backend was built from — moved into docs/BUILT.md instead, and its entries
point there in one line.
Every entry was checked against the tree before it got a keyword, and the
prose was wrong in both directions. Things the deleted files called open were
built: the first-evaluation stall, main being redefinable, macro parameter
lists, the type-limit constants, the array constructors, the byte fills,
inc/dec, the discard's fontification, the Emacs buffers, rt_die's _exit, the
backtrace surface, and the acceptance failure that could print and still exit
zero. Things they called done were not: the backend reports' no-plan buckets
had gone stale in the other direction, the value-dependent defvar was
superseded rather than built, and macro-expansion source locations are on an
unmerged lane, so that entry is NEXT and names the branch.
Every comment that cited one of the five by name now cites a heading that
exists, in TODO.org or in docs/BUILT.md. The session reports under
docs/handoffs/ keep naming the files they worked on, because rewriting them
would falsify what those sessions did; each carries a note saying where the
content went.
Review follow-ups on the mode pass.
The regression first, because it is the one that cost something: giving
macros a kind of their own took them out of three completion tables.
flan-disassemble, flan-disassemble-ir and flan-lowering each filtered
flan--defs on kind "fn", which had been the whole truth right up until
this branch made it half of one. A macro is compiled -- defmacro is a
defn by the time anything emits code -- so all three genuinely work on
one, and only the offer had gone. One flan--compiled-kinds names both
words and the three sites read it.
Then four sentences in the FIX.org entry that were not true, which
matters more than it sounds: that file is the history somebody reads
later to find out what happened.
The corpus claim was the bad one. It said the whole corpus round-trips
with zero differing lines. It does not, and never did -- the script that
measured it bound inhibit-message around its own reporting, which in
batch means the differences were found and then swallowed. Measured
properly: 317 files, 22 files and 389 lines differing before, 20 and 373
after. Sixteen lines fixed in two files, no new difference introduced,
and 373 lines still differing that this pass never looked at.
The other three: the fns/macros dedup is required by the new rows and is
not a fix to a bug that was there before -- a macro used to appear once,
as a fn. handler-case was already a keyword. loops.flan has three
labelled loops and dotimes-range.flan has the other two.
And three gaps the review found while checking: array-fill and array-gen
were never in the keyword list, Unit was in the type rule while the
parser refuses the word, and a prelude macro's row carried a bare name
where a program's carried its parameters. A check that pulls every head
out of parse.ml and diffs it against the three lists now comes back
empty, which is what the docstring had started claiming.
flan--special was three kinds of name in one list: the forms the parser
dispatches on, the functions the compiler provides, and the words that
stand for themselves. Drawing push like let said they were the same kind
of thing. They are three lists now, each read off the file that decides
it, and the two names in the old one that are not in the language at all
-- cast and none -- are gone with it.
What else had been missed: dyn, Allocator, Vec, Map, Fn, int and float
were not types, $t was not anything, handler-case was not a keyword, a
comma was not whitespace, & was not a name character -- which a macro's
parameter list needs, now that it destructures -- imenu had no heading
for a macro and none for the four dispatch forms, and a labelled loop
indented its body under its own binding vector, which loops.flan has
four of and would have said.
Then the second half. The defs op already told the editor what every
name is, and only completion and eldoc were listening, so a macro you
defined looked exactly like a function you defined. It carried no macro
kind to listen for and could not have: a macro is a defn by the time
there is a program. So the op answers with one -- keeping the location
off the defn it drops, so M-. still goes there -- and with the type
names, read off the checker's environment, since an enum is an i32 and
an alias is gone by then.
The editor draws them by kind, after the static rules and never over
them: a program defining its own length does not get to repaint the
builtin. A hash table and a matcher rather than a regexp of every name,
rebuilt when the cache is and not when a key is pressed. With no session
the rules come off and the buffer is what it always was.
spec-memory.md's case 2, capture by value into a stack environment, and
the calling convention the author's rulings asked for.
(Fn [i32] i32) captures; {code, env}; the common case
(CFn [i32] i32) the bare address; one word; cannot capture
A local of the enclosing function that an fn names is copied into a
struct the checker synthesises, held in a slot of that function's frame,
and the value carries its address; the lifted body reads the copies back
into named slots of its own, once, at entry. So the name in the body
means what the local held at the instant the value was made --
fn-capture.flan changes the local through a pointer after the value
exists and the fn still answers with the old one.
Two types rather than a uniform environment parameter: "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." The environment 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 -- and by nothing else. An
ordinary defn emits the signature it always did; calc-me and fourteen
corpus programs were diffed to say so.
CFn, because the C carries information: a value with no environment is
the only kind that could ever cross to C, and under the --no-conditions
direction FIX.org records it becomes literally a C function pointer. It
is not that today -- a declare cannot take a function type at all -- and
crossable's refusal says so where a reader would otherwise be misled.
Nobody needs CFn: Fn accepts everything, and the commonest reason to
reach for the narrow one is that a *named* function handed to an Fn pays
a hop through the widening thunk where a CFn is a direct call.
That thunk is one small function per distinct signature widened, which
reads the bare address back out of the environment and calls it. The
cheaper trick -- the environment last, ignored by a body that never
declared it -- is legal under SysV and is a trap under wasm32's
call_indirect, which compares the signature at the call. Every indirect
call is exactly typed now.
A handler clause captures the same way and is sound with nothing left
over: its frame is popped by the body that pushed it. What is refused
there is a *store* into a captured name -- it is a copy, and writing to
it would leave the local as it was.
And the other half, which is what "non-escaping" means: a value carrying
an environment may be called, passed down and let-bound, and may not be
returned, stored, pointed at or pushed into a container. A parameter of
type Fn is treated as one, which answers "passed to something that stores
it" with no interprocedural analysis -- the store is refused inside the
callee. Everything of type CFn is clean for free, which is the second
thing having two types buys. Every refusal names case 3, the environment
the collector owns.
Two pre-existing bugs fell out on the way. A lifted fn asked for Fnval,
so `flan reload' on any function containing an fn literal died at llc
with an undefined cell; it takes Flanfn now, which is the choice a
handler clause always made. And a redefinition module now carries its
own hidden copy of every thunk it names, which is the same bug shape
caught before it shipped.
Follow-ups to the re-run flip, and all of one kind: each said something
correct about a committed re-run that still read as parked, and the flip made
it false.
flan_merged_park's note that the program stays PROGRAM_PARKED across a poll is
now true of every round but the one it leaves on — which is the round that
drains the ring with a run already taken, and the round the fix's own argument
leans on as the long one.
Program.rerun's refusal told the reader to close a window or let the program
finish. With a thunk stopped in the break loop a re-run is accepted, the state
goes running, and a second is refused by that sentence — advice about a game
loop for somebody whose thread is in a break. The C cannot see a break; this
end can, so Dev.rerun passes what it already asked the agent and the refusal
says resume or abort instead.
The five-second timeout in eval_expr and run_render_thunk reached its
break-loop sentence through the Parked arm, so a program stopped between runs
was asked whether it calls (agent/poll). Both gain the arm that names the
break. run_render_thunk's two sentences become a function first: the new one
asks the agent, and that was the body of a five-millisecond tick.
docs/BUILT.md gains the case an editor author meets — :parked nil :stopped t
with no frames running — and FIX.org records that test_dev.ml:763's refusal
assertion was racy before this and is narrowed by it.
flan_merged_rerun accepted a request under the lock and left program_state
as it found it: PROGRAM_PARKED, until the parked thread got round to waking.
describe's :parked reads that same state, so a caller that asks for a re-run
and then waits for the program to park again was liable to be answered by the
park it had just ended — the wait fell through on the old park, the next
request went out before the first run had started, and the pair of them made
one run between them; or the thread woke in between and the second was refused
as "already running". One lagging state, two symptoms, and all four of
test_dev's re-run sites could show either under load.
The window was documented over flan_merged_park as "as wide as a flush". It
stopped being that when the ring drain went in front of the park's exit: the
leaving round loads whatever was queued before it breaks, so the window was as
long as the next thing the program had to do.
The store moves to the acceptance, under the lock that made it. There is no
longer a moment in which a committed re-run reads as parked, and the park's own
store on the way back into main stays as the no-op that says where the thread
has got to. Dev.rerun reads the liveness and the break for its note before it
asks, since afterwards the answer is running by construction.
Measured on that loop driven standalone against programs/dev-rerun.flan: 9
failures in 25 runs under two busy-loop burners before, 0 in 50 under the same
load after. FIX.org, 2026-09-21, has the readers that were checked, the path
that cannot exist, and why the two-process daemon has no such window.