53 Commits

Author SHA1 Message Date
a26469894e The prelude's second tier, which returns things instead of filling buffers 2026-09-12 22:05:22 +07:00
a8a7ebc3f6 The refusal block loses half its entries, and the four that stay say why
The list at the foot of prelude.ml was one sentence -- every entry needed to
produce bytes that did not exist in its input, and there was no allocator --
and that sentence has been false since Vec landed. Seven entries move up into
the code, and string-from-bytes turns out to have been the `string` builtin
all along: (string (as-slice v)) is the round trip, free precisely because
the layouts are identical.

What is left is refused for four different reasons and is written that way
now: pad and center for nothing at all except that no caller has asked;
format and sprintf for variadics of mixed type; map, filter, reduce and
sort-by for function values; map-keys and map-values for a map iterator that
does not exist in the runtime.

NEXT.md's queued section is struck and carries the four findings, each with
the change it wants named -- flan_map_next plus one builtin for the iterator;
milestone 5's function values for the higher-order three; vec_new_elem taking
a type expression rather than a bare name, which is what forces slices-new to
exist; and an array literal with no way to say it is [f32], which is what
forces every float in algorithms.flan to be cast. BUILT.md gets the section.
2026-09-12 22:01:30 +07:00
eda460b052 break and continue, with labels, and an array constructor 2026-09-12 22:00:39 +07:00
3f097de522 Pin the latch to the continue path, and the clause barrier to its reason
A dotimes whose every iteration continues still counts to its trip count. The
existing case fails by hanging if the latch is wrong; this one fails by
counting wrong, which is the off-by-one the four-block layout could have.

A restart-case clause was made a barrier on reasoning alone and nothing
observed it. Now something does.

And say what a labelled continue means, which is the half that is not obvious:
it advances the named loop's counter and skips the rest of its body, not just
the rest of the innermost one.
2026-09-12 22:00:13 +07:00
008165335d break crosses only sometimes, so the refusal is relative now
return is refused inside handler-bind and restart-case blanketly, and rightly:
a return always crosses the frames they pushed. A break does not. A loop
written wholly inside a restart-case body has a perfectly good local break, so
the rule is a barrier on the loop stack rather than a flag — a jump is refused
exactly when a barrier stands between it and the loop it names, and the message
says which construct. handler-bind and restart-case bodies are barriers, so is
a restart clause, so are a defer's forms; a handler clause is lifted into its
own function and needs no rule at all. in_frames is untouched: a return is the
special case where the target is always outside every barrier.

continue wanted the other blocker. check_dotimes folded its step onto the end
of the body, which a continue would jump past, so the counter would never
advance and the loop would hang. Tast.While carries a latch now — condition,
body, latch — the step goes there, and emit_while emits four blocks. A while's
latch is empty and folds away.

Labels are Odin's, in the head position: (while :outer c ...) and (break
:outer). A keyword there is unambiguous because a loop condition is never one,
so one label function serves while, until, dotimes, break and continue. It is
not a goto — the checker resolves a label against the loops the form is
lexically inside, so control can only leave a loop it is already in.

Break and Continue carry a relative depth rather than a name, because that is
what a backend already has: emit keeps one entry per While the way it keeps
one pad per frame, and indexes it.

Nothing in the prelude wants either. Every early exit there is a return from
the function, which break cannot replace; the sentinel-flag loop break exists
to remove does not appear in it. The two the compiler emits are that shape and
are the one place it cannot help — their sentinel is set inside a restart-case.

reach.ml and render.ml take the While arity change and nothing else.
2026-09-12 21:58:53 +07:00
b71b7a7981 Valgrind over the corpus, in 88 seconds, with no suppressions to write 2026-09-12 21:49:50 +07:00
9ebee780e7 What memcheck sees that a sanitizer cannot, and where it stops seeing 2026-09-12 21:49:20 +07:00
9f4a59c56f A let binding has no type slot, so (array 4 T) is how you say it
[4 T] is the type syntax and is unchanged; it already works in a defvar, a
parameter, a field and a return. A let binding is the one position with no
type slot, and there the brackets are an array literal of two elements whose
second is a type name — which came back as "unknown name rl/Vector2" and cost
32 hand-written Vector2s in one raylib example.

(array COUNT TYPE) is a parser form rather than a builtin call, because the
second argument is a type and the parser's callers have none. Parse assembles
the Tarray itself, so the count takes a constant's name for free and a value
in the type position is refused by the type reader's own message. The checker
resolves it to Tast.Zero — no new backend node and no new type.

(zeroed [4 T]) was proposed first and rejected: the parser can tell, a person
cannot. zeroed keeps its job of being inferred; array is the one that is told.
2026-09-12 21:48:08 +07:00
49bb9b9c42 Macros expand, and unless is a prelude defmacro 2026-09-12 21:11:40 +07:00
64d4993fee The expander is written down where the next person will look
BUILT.md gains "Macros: the compiler dlopens the program": the image format and
why nothing aggregate crosses to C, quasiquote before the walk and why that is
load-bearing, the distinction between a quasiquoted call and a real one, the
two different non-termination failures, -linkall and the session-path argument
that forced it, the three cost numbers, and what unless proves and what it does
not.

"Why there is no interpreter" gains its consequence: compile-and-dlopen is a
mechanism now rather than an absence.

NEXT.md loses the whole expander design and its handoff, which are done, and
keeps six things that are not: four special forms left, with why when and
dotimes are the hard two -- the prelude uses them 29 and 12 times, so moving
either makes the prelude depend on the macro the macro module has to compile
the prelude to get; that a macro has no way to say why something is wrong,
which is the biggest gap and the reason unless went before cond; macros not
imported; a prelude macro not being able to call a macro; nested quasiquote;
and gensym's counter per module.

One claim corrected rather than left standing: the packages section said the
topological package order exists for the expander. The expander does not read
it, because macros are not imported. The order is right and correct and
nothing uses it yet.
2026-09-12 21:07:06 +07:00
0389c2282c What the second root can reach, what it needs, and why both are kept
The daemon side and the Emacs side both landed with nothing written down. Four
files owed something.

`BUILT.md` gets the whole of it: why rooting at an address alone was rejected
and why that rejection was half wrong, what a path step is and how a union's
case travels with it, why the slot goes by index and not by name, and the two
capability lists side by side — the expression root works on a running program
and cannot name a frame; the slot root names one frame and one slot and reaches
an option's payload and a union case's fields, and needs the program stopped.
Neither contains the other, which is the reason there are two.

`emacs/MANUAL.md` says the same thing in the register that file uses, under the
inspector, because the person pressing `i` is the one who needs to know which
root they got and what it cannot do. The globals section's claim that `i` works
on a global "exactly as it does on a local" was true and is now the interesting
difference, so it says what the difference is.

`NEXT.md`'s decided item is struck with what actually shipped: a frame and a
slot index rather than an address and a type, and `l` crossing between the modes
was predicted as a cost and turned out not to be one.

`DISCUSS.md` item 1 is no longer an open question. The number stays — cimport.ml
and NEXT.md cite these by number — and what stays with it is the one correction
worth keeping: an address is not an expression, but a step does not have to be
one either.

And BUILT.md's last paragraph still said `render.ml` prints `(V {:x 1.5})` and
that the printer would move when its reader did. They moved together some time
ago.
2026-09-12 20:36:48 +07:00
66c29dfa5f A union is a tag and room for the largest case 2026-09-12 17:04:18 +07:00
cd34c3fea9 A union recurses through a pointer, and not by value
check_finite already walked a union's cases, so a union containing itself by
value was refused before the emitter could try to lay it out -- which it would
have done forever, since payload_lay calls lay calls payload_lay. Asserted
both ways round: directly, and two unions through each other.

Through a pointer it works, and that is the shape a Form has, so it is in the
program rather than only in the prose: a Tree with a (Ptr Tree) field, matched
through a deref, summed recursively.

BUILT.md also records why match's fall-through is still unreachable rather
than a trap. It is only sound because no reachable program can hold a tag no
case names: Zero is tag 0, every construction writes a tag the checker
resolved, and uninit -- the one way to get bytes nobody wrote -- is refused on
a union for exactly this reason. The refusal is what pays for the unreachable.
2026-09-12 17:02:59 +07:00
d5901e809b What a union is, and every decision the spec did not settle
BUILT.md gets the section and NEXT.md's item 5 and its diagnostics bug are
struck through.

The decisions worth recording are the ones nothing upstream had made: an i32
tag, a payload aligned to the widest member of any case, qualified
construction and bare patterns, declaration-order tags -- so case order is
part of a union's contract the way field order is a struct's -- and a
non-exhaustive match refused rather than defaulted.

And the one finding the macro lane needs: an imported union is still refused
at load.ml:312, but the prelude is prepended into the same flat namespace
before collect runs, so a defunion Form in prelude.ml needs no import and no
load.ml change. Verified by declaring one there and matching it.
2026-09-12 17:00:47 +07:00
f3f973d775 A second fingerprint for the globals a body names, and the header caches at both levels 2026-09-12 17:00:20 +07:00
36c3e5a56d The globals a frame names, checked the way its slots already were
The globals section attributed a frame by its slot fingerprint, which is the
wrong cut for it: a redefined body can name entirely different globals while
binding identical locals, so the check saw no change and the new body's
reference set went into the union under the old body's frame, with the frame
numbers beside an entry saying so.

So a second fingerprint. Reach.ref_fingerprint hashes the set of globals a body
names — sorted and deduplicated, because a reference set is not ordered, where
slot indices make the slot fingerprint order-sensitive on purpose — and it
travels the path the first one already cut: %fninfo, flan_dev_frame_refsig, the
agent's snapshot, the backtrace line, Dev.globals_op. Different means the frame
is skipped by name with its reason, and the rest of the stack still contributes.

Two numbers rather than one, because they are two facts. A frame whose slots
match and whose globals do not has locals that are perfectly readable and
attribution that is not, and a combined hash would make locals refuse a frame
with nothing wrong with it. locals still checks the slot fingerprint alone.

It lives in reach.ml because expr_refs is already the walk that answers what a
body refers to, and is the walk the union itself is built from. One consequence:
emit now reaches reach, which closes a cycle through Load if cimport calls
Build.cachedir, so the header cache spells the object cache directory itself.

test_dev.ml drives the exact case — a body that binds identical locals and names
untouched where the stopped frame names pressure. With the check disabled it
fails twice: the missing refusal, and untouched appearing under frame 0.
2026-09-12 16:59:44 +07:00
df1a43d3ac The header cost was never on the redefinition path, and here is the split
The 15.5ms attributed to re-reading the header on every reload is not that.
A timer around each stage says the cached dump reads in 0.33ms, the extraction
takes 3.3ms and the checks 0.55ms — about 4ms, once, in Session.create. The
rest of flan reload's delta is Load and Check over 256 more declarations, and
the +3.6ms a redefinition really pays is Check and Emit.redefinition against a
bigger program. A C-c C-c reads no header at all: eval's forms carry no import,
so no package is read.

Both cache levels anyway, because a long-lived process should pay nothing
twice. In the session, two tables: the dump by header, the declarations by
header and by what the package already declares. On disk, the existing cache
moved into the object cache directory beside the .o files. The in-memory key
is the path and the flags with no mtime, so a header edited mid-session is not
picked up until the session restarts — the rule a changed .c file follows, and
the rule that keeps new signatures from being checked against a process still
running the old layouts.

Measured: repeat import 3.65ms to nothing; flan reload unchanged, as it must
be, since it imports once per process.
2026-09-12 16:53:23 +07:00
dc73b63446 Say that a ring is refused, since the last note said the opposite
BUILT.md described a tolerated cycle as a property — "mutually dependent
packages simply work" — and NEXT.md still listed a package importing a package
as the real gap, which it stopped being some commits ago. Both now say what the
code does.

Written down with them: what a name imported through an intermediate package is
called, and why the inner alias is forced rather than chosen; that the diamond
is proven by the numbers pkg-diamond prints rather than by its compiling; and
that pkgs is topologically ordered while the declaration list deliberately is
not.

Package visibility stays on the list. The gap is that a package has no way to
mark a name private, which is surface syntax; the predicate and the refusal it
would hang off are already there.
2026-09-12 16:47:06 +07:00
03e8a1fd1b A Map, open-addressed and Robin Hood, over the type-erased runtime 2026-09-12 16:35:01 +07:00
ea24461107 Cover a dev build and a map that leaves its let
Two gaps nothing in the suite reached.

A dev build, because the hash and equality pair emitted for a struct key
is a function nobody wrote, and the only other inhabitant of the lifted
list — a handler-bind clause — carries a parent this one cannot: the
pair is shared by every function that maps that key type, so it has no
single parent. A dev build puts every body behind an indirection cell
and is the build that would notice. It does not; maps.flan answers the
same nineteen ways at --dev as it does at -O2 and -O0.

And a map crossing a function boundary in both directions. Everything
else in the file lives and dies inside one let, so nothing would have
noticed if the 48-byte header travelled wrongly by value while every
runtime operation takes its address. Returning one and passing one are
both moves, which is the rule a Vec already follows — verified against a
Vec rather than assumed, since a refusal that fired for the wrong reason
would look the same.

has-key? is flagged in BUILT.md as what it is: an addition, not
something spec-memory.md names.
2026-09-12 16:34:25 +07:00
b2059520ab Say what the Map is, what it cost, and where it loses
BUILT.md gets the Map and the defer relaxation; NEXT.md strikes step 4
and item 3, and records four things that are genuinely open rather than
finished.

The one worth reading is that the Map is slower than CPython's dict at a
million entries while being six times quicker cache-resident. Both are
memory-bound at that size and this layout waits longer: keys, values and
hashes are three separate runs, so a lookup that misses everything costs
three cache misses where a compact dict costs two, and the hash run is a
full eight bytes a slot. Cell packing buys probe locality, which is a
win while the hash run is resident and a loss once nothing is. One byte
of metadata a slot is the known answer and is not built, and the
crossover between the two results is somewhere nobody has looked.

Also recorded: the defer change amends a frozen spec-memory.md, which
said a defer for a let-bound value was not expressible; and the Map is
narrower than the spec on one point, a fixed array being a key only when
its elements compare bytewise.
2026-09-12 16:29:27 +07:00
3325c41fb7 The globals a stopped stack touches, in a section of their own 2026-09-12 16:23:31 +07:00
635d12782d The globals a stopped stack reaches, in one section and not under a frame
A global is program state a frame happened to touch, not part of it, so
nesting it under one implies an ownership that is not there and repeats the
name once per frame that reads it. One section instead, holding the union of
the globals every frame on the stack references — the compiler does the
choosing, since Reach.expr_refs already answers a body's reference set, and
listing every global a program has would bury the one that matters under the
prelude's PRNG state.

Each entry says which frames touch it, by the index the stack section already
numbers them with, which recovers what per-frame nesting would have told you
at no cost in duplication. Ordered by the innermost frame that touches it:
a deep stack makes the union large and proximity to the error is what puts
the likely culprit on top.

Simpler than locals, because a global is reached by name rather than by
address. Emit.redefinition writes a global the host has as external, so the
thunk binds to the program's own storage and nothing is asked of the stopped
thread — no dev-slot round trip and no not-yet-bound case to refuse.

A frame that cannot be attributed contributes nothing and is named in
:skipped; the union being incomplete and the union being complete are
different answers. The hole in that is stated rather than papered over:
slot_fingerprint hashes a body's slots, which is the right cut for locals and
not for this, so a body that names different globals while binding the same
locals is not caught. The test drives the case that is.

MANUAL.md also loses a stale paragraph claiming the fingerprint check never
fires with a failing test pinned to it. It fires, and test_dev covers it.
2026-09-12 16:22:58 +07:00
9d6784f2cd Write down what was read, what was refused, and what it cost
BUILT.md gains "The header is read now", directly under the section whose last
paragraph promised that reading a header was what would convert the trusted
half into a checked one and that it was not built. That sentence is replaced by
a pointer to the one below it, in BUILT.md and in shim.ml's docstring both.

It records the things worth not re-deriving: why the dump and not libclang (and
that Zig left libclang too, which strengthens the argument rather than weakening
it), why the import is bounded by the package's own defstructs, why generating
defstructs would make the check circular in exactly the way a _Static_assert
was rejected for, refusal-by-demotion from Zig's failDecl, the naming rule and
what it must actually guarantee, and both const-vs-non-const char * and the
target-varying widths.

The diff and the costs are stated as measurements, with the table: 16 of 16
defstructs and 172 of 172 declare-c agree against 5.5, ten real differences
against 5.1-dev, release +4ms warm, redefinition 31.0 -> 46.5ms.

DISCUSS.md item 6 is rewritten rather than removed. The mechanism question is
settled and is now in BUILT.md; what is left is narrower and is two decisions
that are the author's — whether the header stays a build-time read or becomes a
committed generator, and whether the 172 hand-written lines migrate. Both have
the argument on each side written out, including what migration would lose:
key-pressed? is a better name than is-key-pressed, and an enum parameter
imports as i32 because nothing tells the importer the package calls KeyboardKey
"Key".
2026-09-12 16:17:30 +07:00
6d54a4390e A field label is a dot, and the colon belongs to keys 2026-09-12 15:04:07 +07:00
73fb16bfa3 Two comments the sweep could not reach, and a handoff note that was wrong
check.ml's prose carried struct literals in the old spelling in two
comments the form-level scan does not see, OCaml comments not being forms.

The Emacs handoff said MANUAL.md and flan-mode.el's font-lock still show
the colon. MANUAL.md does not mention a struct literal at all. font-lock
does have something, but it is the opposite of what was written: it colours
:name as a constant and has no rule for .name, so a field label is now
unfontified rather than wrongly coloured. Said accurately, with the line.

runtime/flan_rt.c:256 also shows {:name ...} and is left alone on purpose --
it describes the *printed* form, which still uses colons and is correct.
2026-09-12 15:03:35 +07:00
e992491799 The colon belongs to keys; the prose, the page and the sweep agree now
web/index.html's Flan blocks convert and its output blocks do not, which
is the same split render.ml makes: the printed form keeps the colon until
the Emacs inspector that reads it moves too. Same in BUILT.md.

plan.org, spec-conditions.md and spec-memory.md carried struct literals in
the old spelling and now do not.

NEXT.md decision 6 is struck, and batch item 2 with it, naming what to run
at merge. BUILT.md says why the colon belongs to keys -- mostly that a map
literal wants {:key value}, and two literals sharing one syntax would have
left the reader asking the checker which it was looking at.

The sweep was not idempotent and is now: {.k :hi} -- a field already
converted, holding an enum member -- read as a destructuring pair on a
second run and ate the member. A re-run over a lane's files would have
corrupted them silently, which is exactly what the tool exists to do
safely.
2026-09-12 15:00:34 +07:00
b5d7a6e45f Say what the fingerprint is for, and correct the note that guessed
BUILT.md's locals section said the second whole-frame refusal was a slot count
mismatch. It is a fingerprint, and the paragraph now says why a count could not
have done the job: the case it exists for is a rename, which changes neither
the count nor the types. It also states the bound honestly — a 30-bit hash can
collide, and a collision would reproduce exactly the wrong answer this catches,
but only between two differing bodies of a function whose name already matched.

NEXT.md's item 1 is struck, and the handoff paragraph that diagnosed this is
marked wrong rather than deleted. It claimed every piece was written and one of
five hand-offs was dropping the number; four were never written. The step it
recommended first could not have found that, and a lane stopping mid-repair
should say which pieces it ran rather than which it believes it wrote.
2026-09-12 14:48:45 +07:00
bdb3f105f2 sand.flan builds for the browser, with its brush baked in 2026-09-12 12:09:54 +07:00
c0bf4ac34c Say where sand.flan lands for the browser, and how to open it
BUILT.md gains the section: why the path had to become an embed, the rule for
a target-tagged .c file, why the agent is a no-op there and why that is not the
barf decision reversed, the exact commands including the .html output name and
the server a wasm module needs, and the four things only a human opening it can
settle.

NEXT.md strikes web blocker 1 and rewrites blocker 3 — nothing has been opened
in a browser is still true, and is now the only thing left.
2026-09-12 12:09:08 +07:00
a0e485f5fb A shadow stack, a backtrace, and a stopped frame's locals 2026-09-12 12:08:06 +07:00
0ff4ce56a5 The locals of a stopped frame, read where they live
The half the shadow stack was built for. A slot's entry in the frame is its
address, null until the binding that fills it has run, so "not bound yet at
this point" is a null and needs no liveness analysis. The daemon compiles a
thunk that renders the types it already knows -- Tast.fn.slots, with snames
beside them -- at the addresses the stopped program supplies, and reads the
text back the way C-x C-e does. Nothing is copied out, because a value with
no header is bytes with no meaning anywhere but in the program that holds
it.

That is render.ml's walk with its root changed, which is the pointer-rooted
thunk NEXT.md said this needed, and one new arm in the backend: a cast from
one pointer type to another, which emits nothing.

Only named slots are recorded. A recorded slot escapes and stops being
promotable, and the slots that would cost most are the ones with nothing to
show -- dotimes' bound, the temporaries min and max use, the walk's own
scratch. They are refused by name rather than shown under an invented one.
Recording every slot was built and timed and is inside the noise, so the
rule stands on what it shows.

Four refusals, each by name and with its reason: a slot nobody named, a
slot the program has not reached, a type the printer has no arm for, and
two whole frames -- an evaluation's thunk, and a frame running a body that
has been redefined since, where every slot index would be a guess.

Measured, minimum of nine runs: +61% on call-heavy code over globals
against +33% for the frames alone, 0.06% of a frame at 60fps.
2026-09-12 12:00:29 +07:00
2a5632bca1 Guard the whole embed read, not only the open
On Linux open_in_bin on a directory succeeds and in_channel_length
answers a number; the read is where EISDIR arrives. Guarding only the
open turned (embed "assets") — someone who meant embed-dir — into an
uncaught OCaml exception out of the checker, which is the one way a user
could make the compiler crash rather than refuse. It now says it is a
directory and names the form that embeds one.

Same class, same function family: read_embed_dir tested is_directory
before file_exists, and Sys.is_directory raises on a path that does not
resolve, so a dangling symlink inside an embedded directory crashed
before the existence test ran. The conjuncts are swapped.

slurp.flan gets its dev build, and the compiler-emitted use-value gets
the same unarmed-restart assertion the hand-written one has. It is the
first clause the compiler emits with a parameter — alloc_guard's retry
takes none — so it is worth saying it rides emit.ml's existing path
rather than sitting beside it.

flan_file_read loses its declare: nothing Flan emits calls it, only
flan_slurp_into does, from C. That takes the edit to emit.ml down to
four declare lines and a comment.
2026-09-12 11:50:06 +07:00
6ce4282337 A frame per call in a dev build, and a stopped program can say where it is
plan.org has specified a shadow stack in the dev column since the beginning
and nothing had ever built it. A frame is four words on the calling
function's own stack: the one it displaced, a pointer to a static
description of the function, and two words reserved for its locals. The
name and the location travel on the frame, so a backtrace needs no debug
information, no symbol table, and nothing from the platform unwinder that
plan.org deliberately does not use.

The pop is at every ret, the landing block a transfer leaves through
included. That is the half that is easy to get wrong: a pop written only on
the normal path leaves a dead frame behind every handled error, and the
test takes five breaks and resumes all of them by transfer before asking
for two frames.

(:op "backtrace") answers from a snapshot the stopped thread takes, beside
the restarts and for the same reason, and marks which frames belong to the
program and which to the evaluation the break is inside. It is refused
while the program runs.

Measured, interleaved, three pairs of binaries: 29% on 600 frames of sand,
7.6% on a benchmark that is nothing but calls -- 32us per frame of sand, a
fifth of a percent of a frame at 60fps. An array with a stack pointer was
built and timed as the alternative and is worse on both.
2026-09-12 11:49:25 +07:00
9ab247badf Say why the embed is a constant and why barf refuses rather than lies
NEXT.md strikes decisions 1, 2 and 5, and the web target's "assets are
two questions" item, which the embed answered with a third option
neither half of it considered: make it a compiler feature and neither
question arises. That item's diagnosis was right — the file that needs
the asset is structurally the one file that cannot declare it — and its
conclusion, that the fix must be a link channel or a new declaration,
was wrong.

BUILT.md gets the two sections. The embed one records the choice a
reader would otherwise have to reverse-engineer: the bytes are a Str
node typed [u8] rather than a Bytes prim over a string, because the
prim is identity but makes the node non-constant, and an embed-dir in a
defconst then cannot be an LLVM constant. It also states the .rodata
write hole loudly, because an embedded asset is precisely what someone
will try to decode in place.

The slurp/barf one writes down what the host ABI grew by and why that
much: three POSIX-shaped calls and one reason reader, Vec-ignorant, with
the Vec-aware half as runtime glue rather than a fourth call. And it
records the gap the feature revealed without fixing — a handler that
wants "try to save, carry on if you cannot" has nowhere to go, because
error is diverging and neither restart means give up.
2026-09-12 11:45:41 +07:00
ce59f90707 An allocator, an arena, and a Vec that signals when storage runs out 2026-09-12 11:22:57 +07:00
92ca8c9774 Strike steps 1 to 3, and say in BUILT.md why the built thing is that shape
NEXT.md is what is left, so the three steps go, the four questions that were
unsettled under them are answered or replaced, and the reasoning that outlives
the change moves to BUILT.md: why Allocator being a builtin opaque type made
milestone 5 unnecessary, why with-allocator is its own IR node, why the move
rule unions at a join, what ownership is not transitive through yet and what
each refusal waits on, and why StorageExhausted had to land with Vec.

Four things replace the old open list. The use-after-release question is still
open but now has evidence available for the first time, because there is a Vec
to write arena programs with. The release layout is blocked on the reload path
carrying build flags. The generation word has no reader and will not have one
until a slice can carry a Vec's identity. And the budget is an addition to the
spec that wants folding in or replacing with a growable arena.
2026-09-12 11:15:22 +07:00
e2bafec373 Four runtime defects, and the two buffers that now have evidence 2026-09-12 10:55:15 +07:00
e691512af3 A render thunk that signals never reaches result_end
The thunk calls flan_dev_result_begin before it evaluates anything, so an
expression that signals is stopped inside the seqlock's window — and a restart
taken from that break transfers past the thunk, so the matching end never runs.
An unpaired begin cost nothing while the counter only moved at the end. It
costs everything now: incrementing would leave the count odd for the life of
the process, every later read reporting a write in progress, and C-x C-e dead
until the program restarts.

So begin sets the low bit rather than incrementing, and end clears it by
setting rather than adding. The ordinary sequence is unchanged — 2k, 2k+1,
2k+2 — and an abandoned write is over as soon as the next evaluation starts.

What that does not fix, because one buffer cannot: an evaluation running while
another is stopped mid-render shares the buffer, so the inner value is the one
that survives. That was true before the counter was a seqlock and is not a
regression.

Also noted in NEXT.md: rt_die in flan_rt.c has the same exit-with-the-loader-
lock-held shape the break loop just lost. Not fixed with it, because rt_die is
the non-dev path too, where there is no listener to deadlock against — whether
it should be _exit always or only under --dev is a decision.

And the 4K-cap assertions clamp their own String.sub, so a short body prints a
failure instead of raising out of the test.
2026-09-12 10:53:09 +07:00
33b6a65610 A restart takes arguments, checked against the types the clause declared 2026-09-12 10:51:32 +07:00
7c1fcbff19 A name finds one frame; it does not search for one that fits
§4 meets §3, and the answer a reader will assume is the other one. An
inner (use-value [s string] ...) shadows an outer (use-value [v i32] ...),
so an i32 is refused there and the outer clause that would have taken it
is never consulted. Searching outward for a frame whose signature fits
would make which restart runs depend on the arguments, which is overload
resolution on a dynamic stack.

Also: neither of the new guards is a bounds check, so --no-bounds-checks
does not remove them. A wrong index is a wrong answer; a transfer into a
clause whose parameters were written to a different layout is not.
2026-09-12 10:51:00 +07:00
66dcf30c8a Strike four fixed defects, and say why the dlclose rule has two exceptions
NEXT.md: the ring, the seqlock, the break loop's exit and the leaked handle are
struck with what each was fixed to rather than only that it was. The snapshot
generation stays open — it wants a hook a test can drive, which is a design
decision and not a fix. The four-buffer paragraph is now two and two.

BUILT.md carries the reasoning that outlives the change. "Nothing is ever
dlclosed" is restated as "nothing that published anything is ever dlclosed",
because that is what the rule was always about — being pointed into — and the
two modules that are closed are the ones nothing can point into. Stating it the
weaker way is what made a dropped handle look like obedience.

The agent section gains why a full ring refuses rather than drops or blocks,
and why the break loop leaves with _exit. The renderer section gains why the
result counter had to become a real seqlock and why marking it odd needs a
release fence rather than a release store — a release store orders what
precedes it, so the buffer writes could still be hoisted over it, which is the
original bug with more ceremony.

One correction: the release-build story named flan_dev_result_get as the symbol
that came up undefined. That symbol no longer exists.

dune test green; dune build @sanitize clean.
2026-09-12 10:49:05 +07:00
5184d732c9 Say what a typed restart still cannot do, and who has to do it
The language half of §3's parameters is in; the half that makes it worth
having is not. A break loop chooses by position and has nothing to fill a
clause's parameters in with, and that is now the top item in NEXT.md,
spelled out end to end — the accessors the frame can already answer, the
signature on the wire, and the one store that has to happen before the
channel is aimed.

§3 asks for a clause's report string to be settled before parameters and
it was not. The field is cheap and so is the accessor; the only thing
that would read either is the break loop's listing, which lives in the
agent and the daemon, so it would have shipped as a field nothing read.
It belongs with the editor half, which is changing that listing anyway.
2026-09-12 10:48:21 +07:00
1ea9456e2c A web target, built by emcc, that a raylib example reaches unedited 2026-09-12 10:47:27 +07:00
3e15328acd Where the web target stops, including the one that is a missing include
BUILT.md gains the section on the third target and corrects the claim it
already carried: emscripten_set_main_loop had the browser fact right and drew
the wrong conclusion, because asyncify answers the same fact without cutting
main in half.

NEXT.md gets the four holes. sand.flan has no web build, and the proximate
cause is that vendor/agent/flan_agent.c:426 uses struct timeval without
pulling in sys/time.h, which glibc gives it transitively and emscripten does
not; sand's main calls agent/start unconditionally so Reach cannot prune it.
Beneath the include is the decision worth making rather than patching around:
the agent is a socket server and the browser has no sockets, so the honest fix
is to refuse vendor:agent on a web target the way --dev is refused.

Assets are two questions and only the easy one is about emscripten.
--embed-file is a linker argument and so already expressible as an @web line.
The hard one is that the file doing (rl/load-texture "brush.png") is
structurally the one file that cannot say so: Load hands out lflags only for a
directory package, and main is not exported, so a program can never be one. No
flag was invented for it.

And nothing has been opened in a browser, asyncify's cost is quoted rather
than measured, and audio and threads on web are untried.
2026-09-12 10:45:42 +07:00
41025fc0ac A union is not a missing struct either
layout searched only Tast.structs, so a declared union came back as "no struct
is named X" — which reads as "that type does not exist" about a type the
checker knows. Refused by kind beside the enum, and both refusals now have a
test: a new enum and a new union, evaluated into the session.
2026-09-12 10:42:50 +07:00
a8f08eda6d A struct's fields, answered out of the build, keyed by the name that is an identity
(:op "layout" :type T) needs no running program: the daemon owns the build, so
Tast.structs is already in the session it compiled the process from. The open
question was what T is, and it needs no new machinery — Load qualifies every
declaration at import, so two packages' Missing are a/Missing and b/Missing and
the name is the type id. Emit already writes that same qualified name into
flan_error, so the string break reports as :condition resolves as :type by
construction, which is the round trip the test makes.

A bare name is refused with the candidates rather than resolved to a unique
suffix: resolving it would put back the ambiguity the rule exists to remove.
2026-09-12 10:39:16 +07:00
50ed2cbef0 Merge branch 'sanitize' into dev-loop
ASan was instrumenting none of the Flan half: it is an LLVM pass that
only touches functions carrying sanitize_address, which clang's C
frontend adds and hand-written IR does not. Globals get redzones either
way, which is why it looked right. emit.ml puts the attribute on every
define now, and a control asserts the report.

UBSan reaches no Flan code and no flag changes that -- its checks are
frontend-emitted branches, not a pass -- so shift UB and the NaN cast are
not answerable this way. Left as a compiler question, pinned by a control
that must not report.
2026-09-12 09:38:11 +07:00
3c7fdfc076 The left-redzone claim was inferred, not measured, and was wrong
A negative index into a global is silent in bounds.flan, which is
measured. "Because a global has no left redzone" was the explanation
put on it, and it does not survive the obvious test: declare another
defvar in front of arr and arr[-1] is caught, landing in that global's
right redzone. Underflow detection is a question about what the linker
put in front of the object, not about the access. Corrected in
test_sanitize, BUILT.md and NEXT.md.

NEXT.md's entry also goes back to its stated size. It had grown to 78
lines saying what BUILT.md says in the same commit range -- the
attribute, the -O0 decision, the bounds.flan table -- which is the
half-build-log the file's own header warns about. What stays here is
what is next: the UBSan gap as an undecided compiler question, the four
daemon-path buffers the corpus never reaches, and Valgrind.
2026-09-12 09:34:05 +07:00
c806125742 What the sanitizer sweep found, and what a clean run does not prove
NEXT.md's queued section becomes a landed one. The headline is not the
flag: ASan reaches Flan code only because Emit now attributes every
define, and UBSan reaches none of it and has no lever that would, so the
shift-UB and float-cast items that section listed are still open and are
a compiler feature rather than a flag.

The clean result is written with its reach. println.flan pushes a
1100-character string through escaped[1024] on purpose, so that buffer
is genuinely covered; scratch[64] never sees more than 20 characters;
and the 4K result cap, the dev registry guard, SNAP_MAX/SNAP_NAMES and
condition_name[128] are on the daemon path and not in the corpus at all
-- read, not tested. Two defects fixed, both found by reading. Three of
bounds.flan's six out-of-bounds cases caught with the checks off, with
the other three tabulated and explained, and the caveat that ASan sees
out-of-object and not out-of-subobject access, so three of six is a
ceiling and not a measurement.

BUILT.md gets the durable half: the attribute, the absent UBSan lever,
why --sanitize does not force -O0 when --debug does, and the -O0/-O2
divergence that earned it.
2026-09-12 09:31:32 +07:00