161 Commits

Author SHA1 Message Date
9e6c655116 A let has the function's extent, so a defer may be written in one 2026-09-12 15:18:39 +07:00
8aac059485 Convert the snippets that landed after the sweep, but not the printer's own output 2026-09-12 15:08:49 +07:00
6d54a4390e A field label is a dot, and the colon belongs to keys 2026-09-12 15:04:07 +07:00
cb757868b4 Keep the printed struct a colon; it is a wire format Emacs reads back
render.ml's output is parsed by emacs/flan-inspect.el, which hard-codes
the colon when it reads a field out of a rendered struct. Moving the
printer on its own would break inspection in the dev loop without
breaking a test that says so, so the printer waits and moves with its
reader, in the Emacs lane.

The sweep could not tell a rendered *expectation* from a Flan *source*
snippet -- both are strings in a test -- so it converted both. The suite
named every one it got wrong, and those are back.

emacs/test-flan-dev.el:415 is the one edit inside emacs/: Flan source sent
to the daemon for eval, which the parser now refuses in the old spelling.
One label, in a fixture.
2026-09-12 14:55:47 +07:00
e041b2f26c A let has the function's extent, so a defer may be written in one
defer is a compile-time construct: the cleanup is copied into every exit
path of the function. That is why a loop body and a branch are refused —
a loop body's would fire once at function exit rather than once per
iteration, and a branch would have to express "maybe registered", which
a form copied into every exit path or into none cannot say.

A let is neither. It is not a frame here: its bindings are function slots
like any other and nothing is released at scope exit, so a let at the top
level of a function body has exactly the function's extent and a defer
written in it always registers. It was refused for a reason that does not
apply to it. A let nested inside such a let has the same extent and the
same permission; a let inside a while or an if has the loop's or the
arm's, and inherits the refusal.

The permission is granted again before every form of a body, never once
around the body: check withdraws it as it starts, so granting it once
would let the first defer through and refuse the second — and two
resources acquired in one let is the case this exists for. defer-let.flan
covers that one specifically, along with nesting, interleaved
registration order across the let boundary, and an early return.

The two refusals that stay now name what blocks them.
2026-09-12 14:54:52 +07:00
8e47356592 A field label is a dot now, and the colon is refused where one was
The delimiter is what disambiguates: (.x v) is a call and therefore an
access, {.x 1.0} is a brace form and therefore a construction. The colon
kept two jobs -- field label and enum member -- and this leaves it with
one, keys, which is what a map literal will want.

The old spelling is refused rather than quietly accepted, and the refusal
names the new one. Two accepted spellings is how two spellings become
permanent, and this repo rejects what it does not support and says why.

:keys keeps its colon. It names no field -- it is an instruction to the
compiler that happens to sit in the same brace -- so leaving it alone is
what lets the dot mean exactly one thing.

render.ml prints the dot too, or a struct the daemon shows would not be
Flan anyone could paste back.
2026-09-12 14:51:57 +07:00
9a820d86cd Sweep every field label from the colon spelling to the dot
The script is in tools/ rather than thrown away, because two lanes are
writing Flan in the old spelling right now and their files need the same
pass at merge.

It works on forms, not on text: a keyword becomes a dot only where it sits
in a field-label position inside a brace, so an enum member in value
position, a map key inside an EDN string and a type-position {K V} are all
left alone. :keys keeps its colon -- it names no field.
2026-09-12 14:47:54 +07:00
10b736f23e The slot fingerprint was emitted and never read
The refusal for a frame whose body has been redefined underneath it did not
fire because four of its five hand-offs were never written. `Emit.fninfo` has
been storing `slot_fingerprint` in the last `i32` of every `%fninfo` all along;
`flan_dev.c` called that field `spare`, there was no accessor for it, the agent
never snapshotted it, the backtrace line never carried it, and `Dev.locals`
compared slot counts and nothing else. The handoff note's "every piece is
written and the refusal does not happen" was a guess, and the first step it
suggested — printing both sides of the comparison — could not have found it,
because there was no comparison.

So: `spare` becomes `slotsig` and gets `flan_dev_frame_slotsig`; the agent
snapshots it beside the slot count and puts it on the backtrace line *before*
the location, since the name is the one field that can contain a space and has
to stay last; `Dev.backtrace` parses it; `Dev.locals` compares it against
`Emit.slot_fingerprint` of the body this session holds and refuses by name when
they differ. No change to `emit.ml` — the value was already there.

The mechanism itself is right and stays. `slot_fingerprint` hashes every slot's
name together with the spelling of its type, so a rename that keeps the count
and the types — exactly the case this exists for — changes it. The count check
stays in front of it because its message is the more specific one.

The fingerprint stays off the wire. A hash is not something an editor can act
on, and the refusal says the fact in words: this frame's body was redefined
since it was entered, so its names no longer describe its values.

`test_dev.ml` gains the inverse and the control. A body that drops a `let` is
refused on the count, and `main` — untouched by the redefinition of `look` —
must still answer, which is the assertion that would catch a fingerprint that
never matched anything and made the verb useless while turning the suite green.
2026-09-12 14:47:26 +07:00
bdb3f105f2 sand.flan builds for the browser, with its brush baked in 2026-09-12 12:09:54 +07:00
a0e485f5fb A shadow stack, a backtrace, and a stopped frame's locals 2026-09-12 12:08:06 +07:00
53d49570ec A slot fingerprint per frame, which does not yet catch what it is for
locals compares the frame on the stack against the body the session holds:
installing while stopped is allowed, so the two can be different bodies of
one function, and a rename that keeps the slot count pairs every name with
the wrong value. Emit.slot_fingerprint hashes each slot's name and type,
emit_fn puts it in the frame's static description, the agent reports it on
the backtrace line and Dev.locals compares it.

It does not fire. The test that drives it -- a redefinition that renames
every local of a function that is on the stack -- fails, and is committed
failing rather than deleted, because it is the only record of what is
wrong. Everything else in the suite is green; this one check is red.

It builds. See NEXT.md's handoff for where to look first.
2026-09-12 12:07:33 +07:00
ce346dd972 sand.flan opens in a browser: the sheet is embedded and the agent is a stub
Three things stood between the flagship program and the web target, and each
is answered here rather than worked around.

The brush was a path. (rl/load-texture "brush.png") hands raylib a filename to
open, and a bare relative path means nothing on a target with no filesystem.
It is (embed "brush.png") now, decoded through a new binding —
LoadImageFromMemory, declared (Ptr u8) plus an explicit count because the shim
generator refuses a slice parameter and says so, with a Flan wrapper taking
the slice apart exactly as collision-point-poly? and load-font-ex already do.
One decode now serves both textures: the unflipped upload first, then
ImageFlipHorizontal in place, then the mirrored one. load-texture and
load-image lose their only call site in this repository; that is deliberate,
because a path-based load is the thing that cannot work here.

A package's C may now be addressed to one target, the way a link line already
could. A .c file may carry a tag before its extension — flan_agent.web.c — and
on that target it is compiled and *replaces* the untagged file of the same
base name. Replacement rather than plain tagging, so that teaching a package
about a new target is additive: the file that was right on three targets is
not renamed to say so. Selection is in Build and not in Load, for the reason
select_lflags gives.

The dev agent on the web is a no-op, and the reasoning is written at length in
vendor/agent/flan_agent.web.c. Short version: the agent is a socket server and
a browser has no sockets, so the missing <sys/time.h> was the surface and not
the cause. Refusing vendor:agent on a web target was the other candidate and
is ruled out by arithmetic — Flan has no conditional compilation, sand.flan
calls agent/start unconditionally, Reach cannot prune a package something
reachable calls into, so a refusal means the program does not build for the
browser at all. This does not contradict the `barf` decision made earlier
today. `barf` is asked to make something durable, and a no-op returns success
to a program that now believes bytes are on disk. The agent is asked to accept
redefinitions, and on the web there is no editor, no socket and no session —
--dev is refused by name on every wasm target — so there is nothing to lose.
sand.flan already says the same of a native release build at the call site.

test/test_web.ml builds sand.flan for the browser and reads the module for
brush.png's own bytes, whole. Not "IHDR": stb_image carries that string itself,
linked in from raylib, so it would pass on a build where the embed emitted
nothing. It is not run — node has no DOM, so main reaches InitWindow and dies
inside glfwInit on `window is not defined`, which says the module is live and
nothing about whether the canvas paints.

test/dune gains brush.png, because an embed is read by the checker and the
headless case reaches sand.flan through ../../ from a sandboxed _build.
test_session's C-c C-k case now passes ~origin, which is what both editor
paths already send; omitting it was testing a request nobody makes.

dune test is green. Docs follow in the next commit.
2026-09-12 12:07:16 +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
54c38d0654 The files slurp.flan writes are output, not sources
They were committed by a git add -A taken after running the program by
hand from the source tree. The suite runs it out of _build and cleans up
after itself; this is only for a run done directly.
2026-09-12 11:50:22 +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
fe85ecd246 The three ways an embed is written wrong, each said at the right moment
A computed path, a file that is not there, and a second argument that is
not `string`. The type argument is now settled before the file is
opened: a program asking for a type embed cannot read a file as was
otherwise told the file was missing, and got the real complaint only
after fixing the wrong thing.

A missing asset is a compile error naming it rather than an empty embed,
because an asset silently absent is the class of quiet wrongness the
whole feature exists to remove. An empty *directory* is not that: it
embeds cleanly as [0 EmbedFile] and len answers 0.
2026-09-12 11:43:34 +07:00
fff4f5d985 One source, two outcomes: barf is refused on the web and says so
programs/web-files.flan is built for both targets from the same text and
neither build reads the target anywhere in parse.ml or check.ml. On the
desktop it writes the file and says so; in the browser barf signals a
FileError the program handles, naming the file and reason 4,
file-unsupported. The whole of the difference is one #ifdef in
flan_rt.c, which is where the host ABI is already implemented twice.

The web case is run under node rather than inspected. An artifact-shape
assertion would say nothing about what decision 2 actually bought —
that a program on the web is told its write did not happen instead of
quietly losing it — so the test asserts the refusal is printed and that
the desktop's success line is absent. A silent no-op would have taken
that branch, which is the outcome the decision rules out by name.

The same program embeds a file and prints it, because that is the half
needing no filesystem and no host ABI: the line is identical on both
targets and is the answer for assets a web build has to carry.
2026-09-12 11:41:24 +07:00
f88ce56073 slurp reads a whole file, barf writes one, and failure is a condition
Decisions 2 and 5. slurp allocates, which is why it waited for Vec, and
it follows spec-memory.md's rule exactly: no allocating operation
returns an error, so there is no Result here and no out-parameter. A
failure to allocate is StorageExhausted under retry; a failure to read
is FileError under retry and use-value. The two guards nest rather than
merge, because they are two different failures with two different
answerable questions — the handler that grows an arena is not the
handler that supplies another path.

The restarts are the pair Common Lisp establishes for a file-error.
use-value is a typed restart, the other thing that landed this session,
and this is the first one the compiler itself emits with a parameter.
Its parameter *is* the path slot the attempt reads, so the clause body
is empty: emit.ml's bind_params stores the invoker's argument into the
slot, the clause falls through, and the loop re-attempts against the new
path. Everything is inside that loop, so a use-value naming a different
file re-measures it and re-allocates for its size; the Vec is freed at
the top of each turn, which is why a retry does not leak.

The host ABI grows by three calls and one reason reader: flan_file_size,
flan_file_read, flan_file_write, flan_file_fail_reason. They are
POSIX-shaped and Vec-ignorant — no handle crosses the boundary and
nothing is held between calls — so a second target implements three
functions. flan_slurp_into is runtime glue on this side of the ABI
rather than a fourth call. These do touch paths, which is the widening
plan.org names as the #1 portability risk and which decision 2 took
knowingly; embed is the answer that does not touch them at all.
2026-09-12 11:38:24 +07:00
1d7f5e1c85 Assets are baked in at compile time, one file or one whole directory
Decision 1. Odin's #load and #load_directory are the model, spelled as
ordinary named calls — an s-expression language already has a head
position and does not need Odin's `#`. (embed "p") is a [u8], (embed "p"
string) is a string, and (embed-dir "d") is a [n EmbedFile] sorted by
name.

Two spellings rather than one that changes type with its context. Odin
threads a type_hint everywhere and can afford it; with structural
equality and no implicit widening, the same text meaning two types here
would be a wart. The path is a literal and resolves relative to the file
the form is written in, both of which are Odin's rules and for Odin's
reasons: the bytes must be in hand before any value exists, and a
package's assets must not depend on where flan was invoked from.

The bytes reach the program as a [Str] node typed [u8], not as a [Bytes]
prim over a string. [Bytes] is identity — emit.ml lowers String and
Slice _ to the same %slice — and wrapping the literal in a prim would
make the node non-constant, so an (embed-dir) bound with defconst could
not be an LLVM constant. Both string emitters take the bytes and ignore
the node's type, so it is the same constant either way and one a global
can hold. emit.ml's escape is byte-exact, so a PNG survives the .ll.

The directory lookup is a linear scan in the prelude over a slice of
EmbedFile. A directory embed is tens of entries out of cache-warm
.rodata, and a compile-time perfect hash would be a build-time map with
its own failure modes that nothing has asked for. Sorted because readdir
order is filesystem-dependent and an unsorted embed would make two
builds of identical sources emit different .ll.

The slice points into .rodata, so a store through it segfaults at -O0
and is deleted at -O2 — the same measured trap the prelude's ASCII-case
note describes for (bytes "Hi"). Inherited, not widened; clone into a
Vec for a mutable copy.
2026-09-12 11:36:01 +07:00
ce59f90707 An allocator, an arena, and a Vec that signals when storage runs out 2026-09-12 11:22:57 +07:00
67c9268907 Reach the two paths a new type can die on, and stop println consuming a Vec
The debug-info arm and the structural printer are each a separate path from
everything the suite was exercising: `outputs ~dev:true` goes through the cells,
not through DWARF, and no program printed a Vec or an allocator. That is
NEXT.md's landed item 2 exactly — field_addr took only Types.Named, so the
printer's Option arm had never run and would have died on the first (Option T)
pointed at it. Both arms work; both are now reached, and the DWARF row asserts
the composite's size as well as its name, because an element count that
disagreed with `lay` would print plausible values for the wrong fields.

Printing a Vec did not work: `println` checked its argument as an ordinary read,
so it moved, and every printing of a Vec would have been its last. Printing is a
borrow — the walk goes over the value and keeps nothing.

And `vec-new` with an explicitly named null allocator no longer substitutes the
heap for it. Adopting the context for a *zeroed* Vec is the documented rule;
quietly substituting for an allocator the program named is the same "released
the region / never made one" collapse free-all already traps for, except silent
and found later as a leak. The no-allocator-named case never arrives as null —
the checker passes flan_context_allocator(), which always answers one.
2026-09-12 11:20:56 +07:00
c6f276cbff Say what the two one-line refusal programs are refusing, and why 2026-09-12 11:15:58 +07:00
5aa6c16209 Ownership is not transitive yet, so refuse the three shapes that assume it is
spec-memory.md says ownership is structural: a struct containing a Vec is
itself move-only, free recurses into owning fields, and a field cannot be
freed on its own. None of that machinery exists — it is the recursive teardown
drop brings — and the move rule as written covered only the types Vec appears
in directly. Three ways past it, each of which hands out a second owner of one
buffer:

A struct field of Vec type. The struct copies its header on assignment and
nothing records a move.

A global of Vec type. The dead set is per function, so two functions each
freeing it is a double free nothing could see, and a global read does not go
through the move path at all — even the one-function case was accepted. Half a
rule is worse than none, so the type is refused where it is declared. A global
Allocator is not this and stays legal: an allocator is a copyable handle, and
it is what makes a handler that owns the arena expressible.

A Vec of a Vec. The runtime is type-erased and copies elements bytewise, so
clone would duplicate inner headers rather than copying what they own and free
would drop their buffers. Shipping the shallow answer under the deep name was
the alternative.

All three name drop as what they wait on.

Also: match arms shared one dead set, so `(match o (Some k) (free v) None
(free v))` reported the second arm as a use after the first arm's move — a
legal program refused, the same case that was already fixed for `if`. Arms are
alternatives, so each starts from the state before the match and the union
survives the join.

And a Vec reaching declare-c now says what to pass instead. It was already
refused, by the shim generator's catch-all for a type it does not know; the
reason it is refused is that handing a header that owns storage to C hands out
an owner, and that is worth saying at the declaration.
2026-09-12 11:12:49 +07:00
af8d291154 (Vec T) over a type-erased runtime, with StorageExhausted going in beside it
Two element types, one runtime, and the element type appears nowhere below
the call site: size_of and align_of are produced where the concrete type is
known, which without generics is simply the concrete call site. That is
Odin's arrangement and it is what spec-memory.md specifies. `at` and `len`
were already the names for a fixed array and a slice, so a Vec extends them
rather than adding a parallel pair — the asymmetry `nth` was removed for —
and the value form and the place form go through one helper so they cannot
drift apart.

StorageExhausted lands with step 2 rather than after it, because the
signatures depend on it: `push` and `reserve` are Unit, `clone` is the
container, and nothing grows a Result. It is built out of nodes that already
existed — a while, a restart-case and an error — so the backend learned
nothing about allocation. The restart is established at the failing
allocation, which spec-memory.md names as the exception to "restarts go at
the resync point, once", and the element a push was given is bound to a slot
before the loop so a retry re-attempts the allocation and not the expression.

Move-only is a dead set on the checker context, and it is flow-sensitive at
an `if`: both arms start from the same set and the union survives the join,
so `(if c (free v) (free v))` is legal and a one-armed free still kills the
binding. The case a dead set cannot answer is a move inside a loop — merged
once at the end of the body it counts one move, not two — so that is a rule,
refused with its reason.

Four decisions the spec did not settle:

The Vec header is six words in every build, not four in release. A layout
that changes with a build flag can disagree across the reload boundary
silently: a redefinition module is built by llc and ld against a host built
separately, and nothing makes the two agree on a struct size. The 32-byte
release layout is deferred on that.

A zeroed Vec has a null allocator, and the first operation needing storage
adopts the context allocator. Odin's behaviour. The alternative was refusing a
Vec-typed struct field until drop lands; shipping the null was a null deref on
the first push.

A Vec's length and index are i32, like every other length here. Widening
indices is one change across all the containers, not a Vec question.

`let` has no type annotation, so a local Vec has nowhere to say what it holds
and the element type is written at the call: `(vec-new i32)`. This is not the
explicit instantiation syntax the generics section rules out — nothing here is
generic and the name resolves as an ordinary type. Where the context says, it
may be left out.

The allocator grew a budget: a ceiling on live bytes, 0 for none. The retry
restart is only answerable by a handler that can make the *same* request
succeed, and for a fixed backing store the handler that works is the one that
raises the ceiling — releasing the region a container lives in invalidates
the container, which is what the epoch check catches. The spec's "grows the
arena and then invokes retry" needed something to grow.

The generation word is bumped on every reallocation and read by nothing. The
stale-slice trap it is for needs a slice that can carry the Vec's identity,
and a slice is ptr+len. Said plainly rather than implied by the word's
presence.
2026-09-12 11:07:57 +07:00
4a7b874050 The limits test reads results through the seqlock that replaced the pointer 2026-09-12 11:00:01 +07:00
4a7eaaa425 The six blind spots a mutation pass found, each watched fail before it passed 2026-09-12 10:58:52 +07:00
199ab02a2b Say why the watchdog exits the ordinary way
It calls exit and not _exit, and the reason is the opposite of what the
comment said: the rows that did pass are still in stdout's buffer, and a
watchdog that threw them away would tell you less than the hang did.
Also observed firing from test_dev, which is blocked on a daemon rather
than spinning — the case the reader hang does not cover.
2026-09-12 10:57:50 +07:00
74c6489020 Allocator is a builtin opaque type, so the arena needs nothing from milestone 5
spec-memory.md defines an allocator as a procedure plus an opaque data
pointer, which reads as a function value, which check.ml refuses four ways.
None of the four is anywhere near this: `Allocator` is a `Types.t` case with
no user-writable constructor, the way `string` is a builtin ptr+len, its
procedure is a C symbol the emitter names, and every operation is an ordinary
named call that `check_call` already routes through `named_call`. The one
thing that really does need milestone 5 is a *user-written* allocator — it
wants a defn's name in value position — and that is refused by name with that
reason rather than left to come back as an unknown function.

An `Allocator` value is a pointer to the runtime's struct and never a copy of
one. That is forced, not chosen: the capability set has to be readable from
wherever a container landed, and `free-all` bumps an epoch every container
made from the allocator has to observe. A copy would give each its own epoch
and the dev trap would never fire.

Two decisions the spec left to be made here, both announced in BUILT.md:

`free-all` is retain-capacity — offset = 0, the pages stay — and handing the
pages back is `arena-destroy`, a separate operation. Zig's reset takes a mode;
Odin's arena_free_all is already retain-capacity in effect. Taking the mode
would have grown the operation table the spec froze at four. The epoch is
bumped either way, because the pages being the same does not make a container
made before the reset valid.

`context/allocator` and `context/temp` are dynamic variables with save and
restore, not extra parameters. The spec calls the allocator part of the
calling convention; the literal reading touches every signature, the FFI shim,
the dev trampolines and the reload ABI for the same observable behaviour.

`with-allocator` is its own IR node rather than a let and two calls, because
the restore has to happen on the transfer path too. A body that errors leaves
through the landing pad, and a context allocator left pointing into a region
nobody outside the body has heard of would be wrong in the break loop, which
is exactly where something is about to allocate to render a condition. The
acceptance program asserts that path by taking a restart out of a body.

The backend grew one prim, `Rt of string`: a call into the runtime's C named
by symbol, with argument and result types read off the expression nodes. The
container runtime is type-erased and therefore *is* a list of C entry points,
so one arm covers all of them rather than one arm each.
2026-09-12 10:55:18 +07:00
e2bafec373 Four runtime defects, and the two buffers that now have evidence 2026-09-12 10:55:15 +07:00
9b80fc2084 The 4K result cap and the registry's 4096 names, run for the first time
Neither limit had any coverage: a renderer that emits more than 4K and
a program that introduces more than 4096 run-time names are both past
anything the corpus does, so the truncation and the abort were code
that had never executed. dev_limits.c drives them directly — they are C
entry points with no Flan spelling, and flan_dev.c is compiled into
every build — one process per mode, because the name table never
shrinks and the overflow case aborts.

The cap case pins the length, the ellipsis, a byte from before the cut,
the generation moving exactly once, and the flag being cleared so a
short value after a truncated one does not inherit its ellipsis. The
registry case pins that 4096 fit and the next one stops the process
with its reason. Dropping result_full and moving the slot check by one
were both planted and watched fail.
2026-09-12 10:54:11 +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
aa0b799bb6 Retyping a global across a reload, which nothing had ever done
flan_dev_global hands back the allocation it made the first time a name
was asked for, and compares the size it recorded against the size it is
asked for. Nothing exercised the comparison: v5 is v4 with extra as an
i32, loaded on top of v3, and what it does is abort the process — so it
gets a host run of its own. The message is asserted alongside the exit
status, because a process that died for some other reason is not this
guard firing and the status alone cannot tell them apart.
2026-09-12 10:51:14 +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
3ace7c262f A hang is a failure the suite never reported
The mutation pass turned up one defect that did not make the suite go
red: a reader branch that forgets to advance reads the same character
for ever, and dune test waits as long as it is left to. In CI that is a
job killed by the runner with nothing named and no output to read.

watchdog.ml puts an alarm on every test binary — generous, because an
alarm that fires on a slow machine is a flake — and a five-second one
around each read in test_flan, where the budget really is small. The
first read that does not return wedges the rest, so a looping reader
costs five seconds and names the row instead of costing eight minutes
or never finishing. Both were watched: the string-escape loop now fails
in five seconds with the case named, and the per-binary backstop was
armed short and observed to fire.
2026-09-12 10:49:07 +07:00
e22a8dba82 Two of the four buffers with no evidence now have some
The 4K result cap and condition_name[128] are on the agent's socket path, which
is why the sanitizer corpus cannot reach them: a program in the sweep has no
socket and nobody on the other end of it. test_agent has both.

A 5000-byte string literal evaluated into the running program comes back as
exactly 4096 bytes ending in the ellipsis result_end puts there to say it
clamped — and it comes back through the seqlock's copy, so the cap and the new
reader are pinned by the same case. The header also shows the generation as 1,
which is the count of complete values rather than the raw counter.

A condition class of 198 characters comes back from `status` as 127 and a
terminator. Aborting out of that break is what pins the exit status at 134 now
that the loop leaves with _exit rather than exit.

SNAP_MAX, SNAP_NAMES and the dev registry's overflow guard are still read
rather than tested. Sixty-five nested restart-cases and four thousand interned
names are a lot of program to write for a clamp each, and neither is on a path
this session changed.

flan_dev_result_cap() exists so the size is asked for rather than written down
in two files: "the copy is never truncated" is only true while the agent's
buffer and the runtime's bound agree, and the agent checks that where the copy
happens.

The pipe the queue program blocks on is close-on-exec, or the child inherits
the write end and its own stdin never reaches end of file — it sat in its last
read waiting for a byte only it could send.
2026-09-12 10:47:41 +07:00
1ea9456e2c A web target, built by emcc, that a raylib example reaches unedited 2026-09-12 10:47:27 +07:00
a649a42faa A refusal test that passes for the wrong reason is not a test
Each of the four cases asserted only that the message named the target. Every
one of those paths meets "web: no emcc on PATH" first on a machine with no
emscripten, which also names the target — so on exactly the machine where none
of the refusals ran, all four would have reported that they did. Each case now
names the phrase it expects.
2026-09-12 10:46:50 +07:00
468dab6e4c Restarts take parameters, and the check for them is where it has to be
spec-conditions.md §3's remaining half: a clause binds parameters, an
invoke-restart supplies them, and what a restart takes is compared at run
time because a restart is found by name on a dynamic stack — neither end
of the transfer can see the other.

The parameters live in a buffer the restart-case owns, not the invoker's
frame. A clause runs after every frame between the two has returned (§5),
so anything on the invoking side is gone by then; the invoker stores into
the target frame while both are still alive, which is the one moment they
are.

The frame carries the parameter count and a hash of how the types are
spelled, and every frame carries them whether it takes parameters or not:
a clause taking none has to refuse arguments as loudly as one taking two
of the wrong type. The count is not redundant with the hash — it is what
makes a 32-bit collision between two different signatures harmless — and
the spelling itself rides along so that a mismatch can say what was
wanted and what was given, which neither end alone knows.

The arguments are evaluated into slots before the invoke node rather than
hanging off it. An argument that transfers on its own is then guarded
before anything aims the channel, and a call written in an argument is on
the ordinary walk Reach and Load already do — a node they treat as a leaf
would have dropped the function and failed to link.

The other way a transfer starts is the break loop, which chooses by
position and has nothing to fill parameters in with. It reaches a clause
through the same channel, so nothing downstream could tell the two apart:
the frame is pushed with the buffer marked unfilled and a clause with
parameters checks that mark before reading it. Refused with the reason
rather than run on values no one supplied.

runtime/flan_rt.c gains two message functions and nothing else; the
restart frame's first four fields, which are the ones C declares, do not
move.
2026-09-12 10:46:24 +07:00
8d048123ca What a headless test can honestly say about a page
test_web.ml never opens a browser and never will. What it asserts is the shape
a browser needs — three files, a module that starts with the wasm magic, a
page that references its own JS and carries the canvas — plus the one
execution available without a DOM: node runs the emitted JS and gets "ok".

For raylib it builds core-basic-window.flan unchanged, which is the claim, and
then reads the module for the two things that would be false if the mechanism
were wrong: an asyncify_start_unwind export, and a glViewport import that can
only have come from raylib's web platform. Import and export names are plain
strings in the binary, so this needs no wasm reader.

Both halves probe rather than assume, the way the wasm32 case does: emscripten
may not be installed and the raylib archive is not in the tree, and a missing
piece is a skip with the reason.

The four refusals are asserted by name — --dev, --debug, --sanitize,
Build.shared, and flan run --target=web from the CLI — because "it falls out
of the existing predicate" is the kind of thing that stops being true quietly.
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
79a8142b78 A local shadowing an imported name, in an expression and in a place
Qualification rewrites a package's own names wherever they are used and
has to stop at a binding. Nothing refuses a renamer that does not: the
program builds, runs, and reads the top-level name instead. The package
in shadow-pkg.flan binds locals called limit and sink over its own
constant and var, and the four numbers separate the two halves —
dropping the shadowing check in the expression renamer gives 5, dropping
it in the place renamer moves the 20 onto the package's sink.
2026-09-12 10:40:47 +07:00
b54f24873e The job ring never looked at tail, and the comment described a drop it never did
publish() wrote queue[head % QUEUE] without consulting tail, so the 65th module
queued between two agent/poll calls landed on the slot the game thread was
reading — twenty-four bytes of function pointers copied field by field with no
atomic near them, so the consumer could take half of one job and half of
another and call it. The comment claimed the overflow dropped the oldest
request; nothing did that.

A full ring is refused now, at the sender, before the dlopen. Dropping loses a
reload the sender was told was ok, which is the same lie more quietly; blocking
stalls the accept loop, which serves connections inline, so a program that had
stopped polling would also stop answering status and abort — the dev loop would
have no way to reach a program that had stopped listening to it. The check is
separate from the store because there is one producer: room, once seen, cannot
be taken away.

Two smaller defects in the same file:

A module with no flan_reload_install was refused and its handle dropped on the
floor. Not an exception to "nothing is ever dlclosed" — that rule is about a
module something points into, and this one installed nothing, so no cell names
it. What leaked was the handle value rather than the mapping: dlopen refcounts
by path, so re-sending the same bad file raised a count nothing could lower.

exit(134) from the break loop runs the atexit chain and the ELF destructors,
which want the loader lock the listener thread may be holding inside dlopen. A
program asked to abort would hang instead of dying. _exit, with the streams
flushed by hand at each call site. The deadlock itself is read rather than
tested; what the tests pin is that the exit status is still 134.

programs/agent-queue.flan blocks on stdin so the window is held open by the
test rather than by a timer: it takes 64 modules, refuses the 65th with a
reason, and installs 64 when it finally polls. noinstall.c's destructor prints
while the program is still running, which is the only way to see the close — at
exit the loader runs every destructor whether anything was closed or not. Both
halves fail on the old code.
2026-09-12 10:39:46 +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
86d0c14a45 Three edges of Reach's walk that nothing called
An index expression inside a place, a place under addr, and a
restart-case clause body are each the only route to a function in
reach-walk.flan. Drop any one of the three from the walk and the
function is not emitted, so the program stops linking rather than
answering wrong; each mutation was planted and watched fail here. The
addr case goes through a deref place on purpose, so the index case
cannot stand in for it.
2026-09-12 10:38:40 +07:00
9549d386a1 A sign and an unknown escape are both the reader's business
Two mutations the reader survived: dropping '+' from the number
dispatch, so +5 reads as a symbol nobody defined, and accepting an
unknown string escape as the character after the backslash, so a typo
silently reads a different string. Both now have a row, and the known
escapes are asserted on the decoded bytes rather than through
Form.to_string, which escapes them again and would compare the source
with itself.
2026-09-12 10:35:33 +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