flan/FIX.org
2026-09-20 20:27:35 +07:00

206 KiB
Raw Blame History

Stuff I've found

Why do I need to call flan to open another window?

let: flan: the program exited; restart flan dev

I can't eval a toplevel defvar, need to eval-defun (C-c C-c)

For Flans intended live-program workflow, C-x C-e on any complete top-level form should do the natural thing:

  • expression → compile/run temporary thunk; print its value
  • defn, defvar, defmacro, etc. → compile/install it; report what changed

The compiler already has both paths. The current split is an editor/UI artifact: C-x C-e is wired directly to eval-expr, while C-c C-c is wired to declaration reload. It is not a fundamental limitation.

A good fix would make C-x C-e context-aware: if the enclosing form is top-level, send it through the declaration evaluator; otherwise use expression evaluation. Then C-c C-c can remain a convenient explicit “reload this definition” alias, but not the only way defvar works.

I can't eval a top level Vec

slurp returns (Vec u8), an owning, move-only buffer. Flan currently forbids every move-only global because it has no global ownership/lifetime model: any function could read and free it, while ownership tracking only exists within one function.

For data that is fixed at build time, use an embedded immutable array instead:

(defconst the-data (embed "game-data.edn"))

That produces a fixed [u8], not a heap-owning Vec, so it can live globally. It also resolves relative to sand.flan.

If game-data.edn genuinely must be loaded at runtime, then today it has to be owned by a local—typically load it in main and pass it through the functions that need it. For a game-wide runtime-owned data asset, that is a missing language/runtime feature, not a bad use case on your part.

The edn module seems to need a struct declaration, it should do both; go into a struct but also return a Map with Vecs and Sets when we don't provide a type

defenum needs optional autoincrementing discriminants

We need a javascript backend so we can reach the world

We need to have C-style unions, maybe those are called defunion, and then sum types are defdata or deftype

Decisions, 2026-09-17

1. Re-runnable main — DISPATCHED

The process does not actually die. [flan_exit_hook] is [flan_merged_exit] (lib/dev.ml:2669): it flushes, reclaims fd 1, and parks in [for (;;) pause()]. What is missing is a way to wake it. The park becomes a condvar wait, a daemon op signals it, and the main thread — not a new one, because raylib wants the main thread — re-enters [flan_program_main]. [alive] gains a third state, parked-and-re-runnable, and each of the ten guard sites decides for itself whether it accepts one.

Globals are NOT reset between runs. That is the CL/Clojure semantics asked for: the process never died, so a second (main) sees what the first one left.

Held for a zeroed global from the day this merged, and did not hold for a computed one until 2026-09-20: re-entering main re-entered the startup function that runs the computed initialisers, so every [defvar] with a call in it was stored back over what the last run had left. Fixed by giving each computed initialiser a guard of its own rather than by changing what a re-run does — see "Per-form initialisation semantics on re-run" below.

2. C-x C-e on a top-level form — QUEUED behind 1

Same file as 1 (emacs/flan.el), so it waits rather than merging by hand. No design questions; the note specifies it.

3. Runtime-loaded owning globals — DISPATCHED

Not a missing global ownership model. One rule: a move-only global is legal, and reading one is always a borrow, never a move. Nothing takes ownership, nothing frees it, its lifetime is the process's. Sound precisely because the lifetime question has a constant answer.

Mutable in place as well — a global Vec can be pushed to. Aliasing follows whatever locals already do; no new borrow regime for globals that locals lack.

[embed] (lib/check.ml:4265) still covers build-time data and is untouched.

4. edn both typed and dynamic — REDIRECTED to arenas, drop parked

Two projects, not one. (read-edn T bytes) does not exist — vendor/edn/edn.flan is only a tokenizer, and the compile-time struct walk is NEXT.md item 9.

[drop] was dispatched to unblock the dynamic half and is being PARKED unmerged on its branch, not reverted, because the premise was wrong. The refusal at check.ml:599 is about teardown, not ownership: the type-erased runtime releases slots bytewise and cannot walk a move-only element. An arena never releases a slot — [free-all] takes the whole region — so the premise does not hold there.

That is also what Odin does, which NEXT.md:1620 already recorded: no destructors, no drop, no finalizers; [delete] frees container memory and nothing else. core:encoding/json ships a hand-written recursive [destroy_value] in the library, and the idiomatic alternative is to parse against temp_allocator and [free_all]. Neither is a language feature. Building [drop] was a departure from NEXT.md:1589's settled "defer stays the answer", taken on the assistant's prompting and withdrawn.

So: lift check.ml:599 for arena-allocated containers, and let read-edn take an allocator — which is already the idiom, since spec-memory.md:283 makes the allocator part of the calling convention with an explicit override.

The real cost, stated because it is not free: [can-free] is a RUNTIME capability on the allocator value while check.ml:599 is a COMPILE-TIME refusal, and the compiler cannot generally know statically that a construction site's allocator is an arena. The spec's answer for the analogous drop case is a check at the point of construction, one branch per container — a runtime branch. This likely becomes a runtime trap rather than a static guarantee.

Ownership tracking itself is untouched. Moves are still tracked; what is given up is freeing one element individually, which is the point of an arena.

5. defenum autoincrement — DISPATCHED

C's rule: no value means previous+1, the first is 0, explicit and implicit mix.

Duplicates: an explicitly written one is an intended alias and is allowed. One produced by autoincrement walking into a value another member holds is an accident and is refused, naming both members.

6. JavaScript backend — HELD

wasm32 already works: test/wasm-run.mjs is a WASI host, the test table runs wasm32 builds, web/index.html is in the tree. A second backend beside emit.ml and x86.ml is the largest item here and the dev loop comes first.

7. defdata and defunion — QUEUED last

Today's [defunion] is already the tagged sum type. It is renamed [defdata], and [defunion] becomes the C-style untagged one. Serves both FFI and type punning, and cimport verifies it against the header where one exists — cimport.ml:295 currently skips any record holding an anonymous union, leaving the defstruct beside it unchecked.

Last, because the rename sweeps parse/check/emit/prelude/docs and every .flan file, and would conflict with everything above.

Open, found while working the list

A transient signal -11 on the globals daemon

Seen once, in one of three consecutive test runs, by the agent doing the C-x C-e work; the runs either side of it were clean. Not reproduced since — three forced full runs (dune test force) are green, 232 checks, 0 failures.

Worth remembering rather than chasing, because the daemon it appeared on is one the move-only-global work (c124df3) changed: test_reload's fixture gained a host global Vec and a run-time-new one. A teardown or a reload module that defines rather than declares a global Vec would strand the block the live process is using, which is exactly the shape a rare SIGSEGV takes. If it comes back, start there.

Status, end of 2026-09-17

Six of seven items are merged on dev-loop and green (dune build, dune test force, @x86, @page). One agent is still running: the arena work for item 4.

item what state
1 re-runnable main after the window closes merged
2 C-x C-e installs a top-level form merged
3 move-only globals, borrowed never moved merged
4 edn dynamic value merged (arena route)
5 defenum autoincrement merged
6 javascript backend first lane IN FLIGHT, 2026-09-17 evening
7 defdata rename + C-style defunion merged

Also merged, not from the list: macro-module symbol visibility, which unblocked [flan dev x86] in one process. sand.flan x86 builds in 292ms against 985ms on LLVM.

Parked branches, kept deliberately

  • worktree-agent-a18e9e62485eaedb5 — [drop] and recursive teardown. Finished and green, not merged. See docs/handoffs/HANDOFF-drop.md, which is the part worth keeping. Withdrawn because the refusal it answered is about teardown, and an arena has none; see item 4 above.

Open, carried forward

  • A transient signal -11 on the globals daemon, seen once, not reproduced. Recorded below.
  • [drop]'s handoff flags that the [clone] / [get] / [map-next] refusals are needed by the arena route too — they are about a copy of a header, which an arena does not make safe — and that a Map has no operation answering where a value lives, which is what reading an arena-parsed EDN document back would need. Both were relayed to the arena agent.
  • [Tast.Addr (Tast.Pfield …)] on an Option: closed, and closed as unreachable rather than fixed. Nothing in the source language builds it. [.field] goes through [struct_target], which admits a struct and a pointer to one and refuses everything else by name with a location — "(Option Point) is not a struct, so it has no fields" — so [(addr (.x o))] never reaches a place for [addr] to take. The node the drop lane hit was one the compiler built for itself. Two rows in test_flan.ml pin the refusal, on the bare field and on the address of one. What is still asymmetric, and is a note rather than a bug: [x86.ml]'s [field_loc] does lay out an Option's tag and value, and [emit.ml]'s [place] admits only a named struct. Neither is reachable, so neither is tested, and growing the LLVM side to match would be untestable code written to balance a path nothing takes.
  • Re-run still does not work under two-process: a finished child is genuinely gone. It now works under x86 because x86 runs merged.
  • sand.flan still holds an uncommitted experiment line that is refused with a message naming the fix: use (defvar the-data (Vec u8)) and fill it in a function.

Landed on dev-loop

Items 1, 2, 3 and 5 are merged and green (dune test force, 232 elisp checks, 0 failures). Items 4 (drop) and 7 (defdata) are still being written. Item 6 — classes and generic functions — is no longer held: it landed, and the M2 queue above records it under item 6 with its commits.

Re-run is merged, and does not work under x86

Park and re-run live in the merged entry point's main(), and x86 refuses the merged daemon by design: a merged host exports every flan.* body for -rdynamic and so interposes the prelude bodies of the LLVM-built macro module the compiler loads into itself. x86 therefore runs two-process, where the program is a child, and a child that finishes is genuinely Gone — there is nothing to wake. [Program.rerun] answers with the two-process refusal rather than the merged one, and a test pins it.

Re-run on x86 needs the merged daemon to accept x86 first. Separate work.

C-x C-e answers against a parked program

The complaint: (+ 1 1) at the top of a buffer was refused with "an expression is evaluated at a frame boundary, and a parked program reaches none". True, and about the wrong thing — the expression needs nothing from the program, and the ones that do need globals the parked process is still holding.

The fix adds a second place a thunk can run rather than loosening what a place has to be. [flan_merged_park] now waits on two flags: [program_asked] leaves the park and runs main, and [program_poll] — set by [Program.wake], which [eval_expr] calls after the delivery — drains the agent's ring and waits again. The thread stays PROGRAM_PARKED throughout, so [:parked t] rides on the reply that carries the value.

Why this is safe without a new concurrency model: while parked there is no concurrency at all. The program's thread is asleep on a condvar, no frame is executing, no global is being written — which is precisely what a frame boundary provides. The break loop is the precedent, a thread servicing the same ring while it is not running frames. Common Lisp answers the same question by giving evaluation a thread of its own (SWANK's [thread-for-evaluation]) and documents the resulting race as the programmer's problem; there is no race here to document.

Merged-build only, and for the reason re-run is: [liveness_of] maps a finished child to Gone under two-process, so there is no parked thread to wake.

What became answerable with it: a thunk can now stop in the break loop on the parked thread, so backtrace, break, restart, restart-at, abort, locals/inspect and globals stop refusing on the state alone and refuse on [parked_break] instead — a paused expression against the park would otherwise be unresumable. Refused still, because their cause is not the park: nothing else.

The headline complaint is verified fixed on sand.flan

Window opened, closed, the daemon reported parked, (:op "rerun") returned ok, and xdotool found a live window from the second run.

The earlier claim that [flan dev sand.flan] failed with "unknown function begin-drawing" was true only on the stale base the work started from, and was retracted after a re-test. Nothing to chase.

Evening of 2026-09-17 — the review, and four lanes off it

docs/REVIEW-production-readiness.md is the production-readiness review, written to be implemented from. Item 4 (arena EDN) merged green before it was written, so the FIX list proper is six of seven done and one in flight.

Four agent lanes are running off the review, each in its own worktree:

  • Tier 1 + the runtime half of Tier 4: overflow guards, map removal (Odin's backward-shift), the registry race, the scratch buffer, Addr-on-Option, the dev-runtime aborts. One lane because they share runtime/*.c.
  • Tier 3: clock, math, getenv, basic file ops. Appends to flan_rt.c in its own section so the merge with the lane above stays clean.
  • Tier 4 without the runtime: CLI error arms, flan run flags and -O, CI, the stale DISCUSS.md x86 table, README's missing subcommands and env-var table.
  • The JS backend's first slice, per docs/DISCUSS.md §5's settled decisions: #_ first, then one function under node, then the corpus with a MATCH/DIFFER/REFUSED survey. New reference clones for it are recorded in docs/REFERENCES.md ("Compiling to JavaScript").

Tier 2 (install and shipping) was explicitly passed over. Package visibility is skipped in every lane — it needs a syntax decision from the author.

All four lanes merged, end of 2026-09-17 evening

Tier 1 (runtime correctness + the runtime strays of Tier 4), Tier 3 (clock, libm, getenv, file verbs), Tier 4 (CLI arms, flags, CI, docs) and the JS dialect's first slice are all on dev-loop. Verified together: dune test force 232/0, @x86 116 MATCH / 0 DIFFER, @js 0 DIFFER, @sanitize clean.

Left for the author, recorded where each lives:

  • Package visibility needs a syntax decision (review Tier 4 item 5).
  • The v->gen word: spec-memory.md mandates it, nothing reads it; delete or implement is a spec amendment (BUILT.md records the two options).
  • sand.flan:167 still holds the refused defconst experiment; the diagnostic now prints in full and names the fix.
  • Tier 2 (install and shipping) deliberately not started.

The repeal, 2026-09-18

The ownership flow analysis is removed: the per-function dead set, the borrow flag, the loop-iteration diff, and the borrowed-never-moved rule for globals. Use-after-move and double-free are no longer compile errors. What stands: move-only as a type property (assignment hands over the header, clone is the only copy), the struct/union/pool ownership rules, defconst-vs-defvar for move-only globals, defer, all allocator capabilities, and the dev build's generation checks — now the primary net, which is the Odin position the memory design came from.

Decided after the bug hunt put four of its ten lanes inside this machinery. An unsound checker is worse than none, because it is believed. The door back is spec-memory.md's provenance pass: removal widened acceptance without changing any accepted program's meaning, so a stricter pass can return additively. spec-memory.md "The repeal" is the amendment; BUILT.md and NEXT.md are annotated at their live claims.

Two of the day's fix lanes were cancelled with this (borrowed-flag, region element); the while-condition fix merged in the morning is deleted again by the repeal, and its pin with it.

The second round, 2026-09-18 — Pool, move-only, and the gen word

Ordered by the author after the ownership repeal, on the same argument: the Odin position, full stop.

  • Pool and (Handle T) are gone — types, checker arms, runtime section, fixtures. Two containers are enough; a slab with generational handles is a library over a Vec when a program wants one.
  • The move-only concept is gone: everything copies as its header, copyable? left the predicate list (four remain), and the struct/defdata/defunion owning-field refusals are lifted. The region rule stands untouched — a container of owning elements is still built against a region and released by one free-all.
  • The gen word left both container headers (read by nothing since it was written). A Vec is ptr len cap allocator epoch; the epoch trap stays.
  • Container globals keep both declaration rules, reworded: they start zeroed (a global initialiser is a compile-time constant, and a container's only constant is the empty one), and a defconst container is refused since a constant is not an assignable place. sand.flan's experiment line would now be refused with the reworded sentence.

Everything verified together: dune test force 232/0 with zero suite FAILs, @x86 122 MATCH / 0 DIFFER, @sanitize clean, and the whole-repo check sweep against the parent differs only where it should: the two negative fixtures now accepted (vec-in-struct, the clause-less generics corpus), the two pool fixtures deleted.

End of 2026-09-18 — the hunt closed out

Ten bug lanes and three demolitions, all on dev-loop and verified together: 232 checks / 0 failures, @x86 122 MATCH / 0 DIFFER, @sanitize clean.

  • Fixed: while-condition move (then repealed with the machinery), defenum i32 range, the Emacs client's framing/poll/point-min/quit bugs, the session NULL-cell rollback, the stdout pipe drains, x86 shift masking, the reversed slice traps in every build, NaN prints unsigned, the registry answers honestly under churn, and reg at's stopped-only race.
  • Removed by decision: ownership flow tracking, Pool and Handle, the move-only concept, the v->gen word. spec-memory.md carries the repeal.
  • Still recorded, not scheduled: trap paths that bypass the break loop, parked orphans outliving dead daemons, emit.ml's transient test and globals, map-grow's stale quote, x86's slice-from-ptr sentence, the float->int UB divergence, the narrowed-buffer C-x C-e quirk, and the JS backend's items (deprioritised).

Late 2026-09-18 — the last two lanes

  • Six trap paths park instead of killing the session — merged (e6d85c8). All six park for inspection; transfer-fail and restart-unarmed refuse the resume with the trap's sentence. New flan_trap_hook beside flan_break_hook, whose contract could not carry these. Standalone builds die as before.
  • Parked orphans exiting with their daemon — merged. PDEATHSIG on the two-process child, armed in the agent, with a spawn-SIGKILL-reap test. Found in passing: the eight orphans split 4/4 — four are MERGED daemons whose editor vanished, a separate defect (accept_loop has no client liveness), recorded here. The eight were killed by hand on 2026-09-18.
  • That second defect is now fixed too. accept_loop keeps a grace since the last client let go of the socket, and ends the session when it runs out. The connection is per session and not per request — Emacs holds one make-network-process for the whole of flan-dev and every deliberate teardown sends [close] first — so an editor left open and idle is an editor still attached, and the clock cannot run under it. Armed only after a first client has connected, so a headless daemon waiting for one is untouched. Two graces: 5 minutes parked, 30 minutes live, because a parked program is invisible (which is why four piled up) and a live one is a window somebody may be watching. FLAN_DEV_CLIENT_GRACE overrides in seconds; non-positive turns it off. Six unit rows on the decision and one end-to-end daemon whose client drops without a [close].

The dynamic half of item 4, finished

Item 4 above is the complaint at line 23: the edn module should read into a struct and answer a dynamic value when no type is given. The dynamic half is now the package's rather than a test program's.

  • [#{}] is read, not refused. The tokenizer's stated reason ("needs a hash set to even represent") was a claim about a reader, and a tokenizer represents nothing; [#{] pushes [}] on the same balance stack [{] does, one new token kind, and [err-set] is gone rather than kept with a new message.
  • [vendor/edn/read.flan] holds the [Value] data type and [(edn/read bytes)], which answers an [(Option Value)] against the calling convention's allocator. A set is [Value.Set] holding a deduplicated [(Vec Value)] — [(Map Value bool)] does not typecheck, because [keyable] refuses a key holding a Vec or a Map, and restricting set elements to keyable Values would refuse [#{[0 0] [1 0]}], which is the file this was built for.
  • A Value COPIES every string into the allocator; a Token stays a view. The two layers diverge deliberately and both headers say so. A view handed out of the function that owns the buffer is a dangling pointer no free-all would even take back.
  • Needed one compiler change to be possible at all: an imported [defdata] was a refusal in load.ml ("not implemented yet, milestone 4"). It is a rename of the type's name plus the [Type.Case] half of a constructor symbol; a match pattern resolves its case against the scrutinee's type and never needed one.

Still not built, still item 9 on docs/PORTING.md's list: [(read-edn T bytes)], the typed half. It wants a compile-time walk over a struct's fields and there is no run-time type information to do it with at run time.

Session close, 2026-09-20 — dynamic-first M1 landed

Merged on dev-loop, all green (dune test force 0 failures, @x86 130 match, @sanitize clean): the dyn type (unannotated defn params/returns are dyn, NaN-boxed runtime, mark-sweep GC, no-gc refuses residual dyn by location), the provider macros (defedn/defjson off macro-slurp; NEXT.md item 9 closed), computed global initialisers on both backends, x86 frame pushes (inspector works under the x86 default), the !-suffix retirement, and the flan-dev→flan rename. typed-flan branch freezes the static language pre-dyn.

Still in flight, worktree branches to merge when they report

  • x86 dyn lowering + the x86/LLVM invoke-restart divergence (one lane, two commits) — the author is waiting on this one to start playing.
  • The writable inspector (SLY-style set + editable render buffer).
  • docs/SPIKE-DUPLICITY.md, the dyn/native boundary audit (report only).

Open, author's call

  • sand.flan holds uncommitted WIP: a defvar initialiser reading game-data.edn at startup aborts the headless import (unhandled FileError at the test's CWD). Options on the table: embed, handler-bind fallback, or harness dep.
  • Signature pairing's cold-rebuild edge: a later type definition can silently re-pair an unannotated parameter vector; a changed-pairing warning between compiles was proposed and not yet queued.

M2 queue, decided with the author 2026-09-20 — in order

  1. dyn maps + keywords (interned, O(1) equality). Retires edn/Value after.
  2. Per-type descriptors: dyn fields in structs/conditions become markable.
  3. Typed containers into dyn as VIEWS — one descriptor word in the box, reads box the element, writes tag-check. Rides on 2. No copies. DECIDED 2026-09-19: the descriptor is its own thing, not the slice type reused. Two reasons. A dyn value is a single word and a slice is two, so reusing the slice buys no allocation back — the descriptor goes on the heap either way. And a slice carries where and how many but not of what, which is the one fact dyn needs, since boxing a read and tag-checking a write both require the element type. The descriptor is therefore pointer, length, and element type: a slice plus the piece a slice is missing. Left open until the lane is built: whether the descriptor points at the container or is a fattened slice stored beside it. That only bites if the container can grow and move, which would leave a push through dyn holding a stale pointer. — LANDED. Settled: a Vec view holds the address of the Vec's own header and reads its ptr/len live on every operation, so a push that reallocates cannot go stale — there is no snapshot to invalidate, because flan_vec_grow overwrites that same header in place. A slice and a fixed array cannot grow, so a flat view snapshots pointer and length once, which is sound for both and is not the weaker half of an asymmetric choice — pointing a flat view at its own value's slot instead would be worse, since a slot's lifetime is not the slice's. The element set is i64, f64 and bool only: a string element's dyn form is a pointer into the collector's heap, and a typed container's storage is memory the collector never scans, so a wider set would let a write plant a live reference nothing traces. (Vec string) and (Map K V) keep the refusal [box] already gave every container. Both backends, runtime/flan_dyn.c and .h, checker tests, an acceptance row per backend, and a survey program (dyn-view.flan) proving the view against both a growing Vec and a fixed array/slice, plus its own two trap modes. REVIEW, 2026-09-20: relocation was proved sound but relocation was not the hazard that mattered — a view can outlive the frame its Vec header sits in, which nothing could reach before this lane because [box] refused every container outright. Three routes, all newly constructible, all stack-use-after-return: returning a view, stashing one in a dyn global, leaving one behind across a condition transfer. AUTHOR'S RULE: on the dynamic side Flan aims where Clojure and Common Lisp are — holding a value should not hand you garbage — so a container may cross into dyn as a view only when its own storage is permanent — a global's. [permanent_root] in check.ml decides it: a global, a field of one, an element of a permanent ARRAY (an element of a slice is NOT — a slice holds only ptr+len, and what they point at can be a frame already gone; the [At] arm steps every index of a multi-index [(at g i j)] the way [indexed] does and demands an array at each level, because the whole index list rides on one node and reading the target's type alone settled level zero only), or a slice cut directly from one at the call (the trace is lost the moment it is bound to a name first). Everything else — a local, a parameter, a temporary, anything behind a (Ptr T) — is refused by name, pointing at the defvar spelling that works. A heap-held header is not expressible soundly at this milestone for a structural reason rather than a missing feature: a (Ptr (Vec i64)) taken off a heap block and one taken off a local are the same type, so admitting a Ptr as permanent would readmit the exact hole this closes. The rule is a narrowing, not a proof, and flan_dyn.h states the property that actually holds: a view is exactly as stale-safe as the thing it is a view of, no more and no less. A global [i64] whose data was cut from a frame that has since returned still passes [permanent_root] and still reads a dead frame. What the guard closes is the routes the checker can see, not every route. An arena-held header is not a separate case for [permanent_root] — an arena changes where a Vec's elements live, never where its own header (the binding) lives, so the cases above already decide it — but that is coverage of the HEADER's lifetime only, and releasing the arena under a live view is a separate hazard handled at RUN time, not here. [view_vec_check] in flan_dyn.c is what handles it: a Vec records its allocator's epoch and every view operation re-checks it, so (free-all ar) with a live view over an arena-grown global Vec traps cleanly and by name at the next read — verified. (arena-destroy ar) is the gap: it frees the allocator block itself, so the epoch [view_vec_check] goes to read is freed memory. Run plainly it happens to trap anyway — the freed block still held the bumped epoch — but that is the allocator not having reused it yet, not a check that held; under ASan the same program is a heap-use-after-free in [view_vec_check] before it decides anything. Left standing rather than fixed with this lane: the typed side has it identically in [flan_vec_check], flan_rt.c, which reads the same freed allocator's epoch, so it is a repo-level question about arena-destroy's ordering and not about views. Three more, all in the runtime rather than the boundary: [view_vec_check] recursed into itself rendering the very view it had just declared unsafe to read (fixed by never rendering it — the sentence names the epochs and nothing else); [dyn_equal]'s VEC arm read raw [len]/[items] regardless of kind, so two views with different contents compared equal and a map keyed by a view collided with every other view (fixed with view-aware length/element readers, [vecish_len]/[vecish_at]); and the three restatements of flan_vec's layout (flan_rt.c, flan_dyn.c, dyn_ops.c) had nothing tying them together despite a comment's claim that they did — a [layout] probe on each, compared field by field in dyn_ops.c's new "layout" mode, makes a disagreement a FAIL line instead of a silent corruption.
  4. nil: arrives with maps. nil <-> None at (Option T) boundaries, trap at bare T, (Some nil) unconstructible. — LANDED, 3c1fb1b. The bare-T trap is split: a literal nil the checker can see is refused at compile time, in expect itself; a dyn only known nil at run time still reaches flan_dyn_need_i64's existing trap unchanged. (Option (Option T)) does not cross either direction, same ambiguity as (Some nil). (Option dyn) is a legal type the boundary code already treats correctly — the payload is the identity, box and unbox both — but not yet a storable value anywhere: the per-type-descriptor pass (item 2) refuses it the way it refuses (Vec dyn), and item 4 does not lift that gate.
  5. Typed = and != grow strings: bytewise, length + same-pointer fast paths, both backends, one survey program. Ordering stays refused. — LANDED, daed039
  6. defclass = named dyn map + shape tag; CLOS class dispatch AND Clojure-style arbitrary dispatch functions. After 1. — LANDED, 8d2bf2a (the feature), 5af990e (the daemon proof and an x86 descriptor fix it turned up) and 6c6024e. Written up below, "Classes and generic functions, 2026-09-20".
  7. dyn if: truthiness (nil/false are false, all else true). Typed stays strict bool. — LANDED, 264765a

    Reaches when, cond, if's own condition, and's condition, or's condition, not and while for free or by hand, all through one funnel in check.ml (check_truthy). Two things fell out of it that nobody had decided going in, one fixed on review and one left as the author's call:

    • Neither and nor or handed back the operand that decided it. Clojure's rule is that both do; each answered a bare bool sentinel on its deciding path instead. and's "false" sat in the else arm, so check_if typed the real branch first and boxed the sentinel to match: an all-truthy and did carry its last dyn operand through, but a falsey one answered false where Clojure answers the falsey operand — (and (box 1) (box nil) x) printed false, not nil. or's "true" sat in the then arm, the one check_if types first, so the sentinel decided the whole expression's type and a later non-bool dyn answer hit the strict bool boundary and trapped: (or nil "x"), the canonical (or x default) idiom, crashed rather than answering "x". FIXED for or in ad0f1fb and for and in this pass: both now bind the test to a temp and answer the temp on the deciding path, Clojure's own expansion — (let [t a] (if t t b)) for or and (let [t a] (if t b t)) for and — evaluating each test exactly once. The asymmetry between the two forms is fully closed; the survey program (test/programs/dyn-if-truthy.flan) pins both, short-circuit and single-evaluation included, and test_flan.ml pins both desugarings down to the bound name and the bound value. Two things came with that pass. The temp binding and the if it feeds now carry the operand's loc rather than the whole form's, which ad0f1fb had lost for or: (or (vec-new i32) v) blamed the enclosing form at 3:13 and now points at the operand at 3:18, and and's second operand gained the same precision. And, noted and not acted on: with both arms of the desugared if now holding real values, a dyn operand mixed with a typed bool one makes check_if unify them by the then arm, so a non-bool dyn value on the losing side traps at the strict bool boundary — (or false (box "s")) and (and (box nil) some-bool) both do. Each form used to be safe in exactly one of those directions, because the sentinel it answered was a bool literal that boxed to fit the real branch; neither is now, and they are at least symmetric about it. (and (box nil) some-bool) printing false is the one previously-compiling behaviour this pass changed. Making a bool arm and a dyn arm join as dyn is a check_if question and the author's call, not settled here.
    • A bare keyword condition used to be checked with want:Bool from the start and refused by the keyword arm's enum-or-refuse case: ":kw is an enum member where an enum is expected and a dyn keyword elsewhere, but bool is expected here", there being no enum in play. Checked with no expectation first, as every scrutinee now is, it resolves as the dyn keyword instead, and a dyn keyword is unconditionally truthy — a typed if with a bare keyword condition now compiles and always takes the then branch. The author's call: lispy truthiness wins here, the lost diagnostic is not brought back. Pinned in test_flan.ml so it does not regress by accident.

    Also noted at check_truthy (check.ml) and not acted on: check_truthy's own retry-on-failure, needed to keep a refused literal's or None's message unchanged, re-runs the whole failing subtree rather than only the leaf that needs it, which is exponential in how deep a chain of nested not gets on a program that does not type-check. Moot for anything that compiles; visible only around twenty levels deep, and only the dev daemon's half-typed-form recompiles could ever feel it. A cheaper retry was tried and shelved — it would need to thread want exactly as far as the full retry already does, or it changes which literal further inside a compound condition gets the nicer message, not just the speed.

  8. Return slot stays mandatory (dyn or ()) — the parse ambiguity it closes is real; revisit only if it grates. SETTLED 2026-09-19, reconfirmed with the author: both spellings stay legal, () is not collapsing into dyn. No work follows from this one.

All of it dispatches after the x86-dyn lane lands. The struct dyn-field refusal (01e60fa) is the stopgap 2 lifts.

The two models, named 2026-09-19

With a collector in the runtime, the direction has a shorter statement than it used to. The dynamic paths mimic Clojure. The static paths mimic Odin. Both carry a little more ML than either of them does.

That is a tiebreaker, not a slogan. A question on the dyn side that Clojure has already answered takes Clojure's answer unless there is a reason to depart, and the same holds for Odin on the static side. Keywords, maps and nil landed under that reasoning without it being written down yet.

Common Lisp is consulted alongside Clojure on the dynamic side, and on some questions it is the better authority of the two. The condition system is the standing proof: handler-bind, the restarts and invoke-restart are Common Lisp, and Clojure has nothing resembling them. handler-case is the same lineage — Clojure's try/catch is the shape most reached for, but the form being added is Common Lisp's, and it is named for the Lisp rather than the Clojure because it is the unwinding half of a pair whose other half is already CL's.

Where the two disagree, the question is which one the rest of Flan already agrees with. Conditions say Common Lisp. Maps, keywords and nil say Clojure. Neither answer generalises to the other's territory.

The ML share is the part neither model supplies — the type system, the options, the exhaustive matching, and whatever a second ML surface would eventually add if the deferred syntax question ever reopens.

Arithmetic semantics do not fork across the two spaces, decided 2026-09-20

One operator, one meaning, both sides. `/` on integers truncates toward zero and `%` is its remainder, sign following the dividend — LLVM's sdiv/srem, the x86 backend's cqo/idiv, and flan_dyn.c's arith all already agree, and that agreement is now the rule rather than a coincidence. The author's call: this sort of semantics is normalized across the dynamic and static spaces, so the Clojure tiebreaker above does not reach it. Clojure's flooring `mod` (sign of the divisor) is NOT to be added as a dyn-side-only behavior of `%`; if a flooring mod is ever wanted it is a second, separately named operation available to both spaces, the way Common Lisp keeps `rem` and `mod` side by side. Division by zero and INT64_MIN / -1 trap identically on both sides, and float `%` is fmod on both backends and in dyn.

handler-case, decided 2026-09-19

Flan has handler-bind, which is the resuming handler: it runs where the condition was signalled, with the stack still standing, and carries on by invoking a restart. What it has no spelling for is the other half — unwind, and answer the whole form with a value. Clojure spells that try/catch and reaches for it constantly; the closest thing here is a handler-bind plus a use-value dance that is far heavier than the intent, or a pre-check that races the read it guards.

The gap showed itself when edn/read-file stopped returning an Option. The caller that used to write or-else against a None had nothing left to write, because the missing file now arrives as a FileError condition and the only concise way to answer a condition with a default did not exist. The shape wanted is:

(handler-case (edn/read-file "game-data.edn") [(FileError [c] nil)])

which keeps read-file's decision intact — the caller still says what a missing file means — while costing one form instead of a machine. Until it lands, sand.flan guards the read with file-exists?, which is a stopgap and racy, and should be rewritten the moment this exists.

The JS backend answers string equality wrongly, parked 2026-09-19

Typed = and != grew strings in daed039, and the JS dialect was not taught the case. A string there is a view object and the arm at lib/js.ml:856 compares with =, which asks whether two views are the same object rather than whether their bytes agree. The arm was unreachable for strings until the checker stopped refusing them, so the lane made an existing hole live without touching the file. Equal literals still answer true, because equal literals intern to one view, which is what makes the wrong answer quiet rather than obvious: (= s (string (slice (bytes s) 0 3))) is true natively and false under target=js.

The author parked it. JS stays deprioritised and the fix is not queued. The option on the table when it is picked up again is a loud refusal in that arm rather than a real implementation, so the dialect says it cannot do this instead of saying something false.

Sweep policy, decided 2026-09-19

A lane runs the fast check and nothing more. `dune test` is the whole of a lane's obligation. It used to be judged by reading the printed output rather than by trusting the exit status, on the theory that some path through the acceptance runner could print a FAIL and still exit 0. That theory did not hold up: test_acceptance.ml is one match on whether clang is on PATH, the wasmtime/raylib/lldb probes inside it are ordinary `if`s that fall through to the same tail rather than branches that leave early, and the tail already turned a nonzero failure count into exit 1 — so did every other test binary's tail, checked the same way. test_acceptance.ml now also carries an `at_exit` guard, but it closes no open gap; it is insurance against a future case leaving past the tail instead of through it. The exit status was already trustworthy and stays that way, so either check does. Running one program directly to capture its real output for an acceptance row is still expected; that is cheap. What a lane may no longer do is sweep.

The x86 survey and the sanitizer sweep run once, after several lanes have landed, and whatever they turn up is dispatched as fixes in a single batch. The reason is arithmetic: a survey walks all 156 programs across three modes, and a lane that touches a handful of them was paying that cost in full to learn nothing about the rest. Paid once for several lanes, the same sweep answers the same question at a fraction of the wall clock. The consequence to accept is that a lane is reviewed on its code rather than on sweep numbers it no longer produces, which is what the review before a merge is for.

handler-case, decided 2026-09-19

Built, both backends, and it needed no backend work at all: it is a handler-bind whose clause invokes a restart the form established around itself, which is spec-conditions.md's one open question about the operator answered in the affirmative. The shape is (handler-case BODY [(T [c] …)]), body first and clauses after, the opposite of handler-bind's order because a handler-bind reads as something put around a body and this one reads as a body with answers hung off it.

Everything the unwinding form needs it inherits. Defers and the with-allocator restore run on the way out because a transfer already runs them for every frame it leaves. The body and every clause agree on one type because §3 already says a restart-case's do, and a clause that disagrees is refused with the same message an if with disagreeing arms gets. A condition no clause lists installs no matching frame and carries on outward untouched. A clause runs at the form, so it sees the establishing function's locals, which a handler-bind clause cannot — that is the whole difference, and it falls out of where a restart clause runs rather than being arranged for.

The one wart, noted and left: the restart the form makes up for itself is on the restart stack like any other, so a break loop entered underneath one lists it. Choosing it there is refused loudly rather than answered wrongly, and hiding it would mean a new field in a frame layout written out in emit.ml, in x86.ml and in flan_rt.c.

Surface syntax discussion, 2026-09-19

The author wants an F#-ish indentation-based ML surface living side by side with s-expressions, not replacing them. The languages that disappear for the author, in the order named: Python first, then Odin, then F#. That ordering is the case for why Flan's own parens might be costing more than they look like they cost.

The architecture agreed if it is ever built: one AST, the existing forms unchanged, and a second reader in front of it. Macros stay usable from either surface, since they operate on the same AST either way. A Nim-style quote-block was floated as the way a macro's own body could be written in the ML syntax rather than in s-expressions, without needing a third representation.

Middle options came up and were set aside rather than chosen. Parinfer stays an editor trick — it never changes the language, only how parens are typed, so it does not touch the actual complaint. Wisp and sweet-expressions (indentation implying the parens) were considered and are closer to a real second surface than Parinfer, but still read as a compromise rather than the ML syntax the author actually wants. A simplified in-paren syntax was also on the table and rejected on the same grounds — it thins the parens without removing them. Rhombus was named as the maximal reference point: whatever a full second surface costs, Rhombus is roughly what it costs to do properly.

Decided: deferred, no spike queued. The author's working hypothesis is that the friction with Clojure may not be the parens at all — it may be immutability, and the discipline of planning a shape ahead of time that comes with it. The plan is to write imperative Flan as it stands and see whether the parens still grate once that variable is gone. Revisit this once that evidence exists.

The x86 backend tracks LLVM -O0, decided 2026-09-20

The ruling, in the author's words: the x86 backend must behave as closely to LLVM at -O0 as possible. A construct LLVM compiles, x86 compiles, and the two must agree on what the program observably does. The backend is allowed to refuse a node it does not lower — that is what X86.Unsupported is for and it is how the survey reports a gap — but a refusal is a bug to be closed, not a position. "LLVM takes this and x86 does not" is by itself a defect report.

What made it a ruling was typed float %. emit.ml's prim arm emits frem for Rem on a float, so (% 7.5 2) compiled under LLVM and printed 1.5; the matching arm in x86.ml had no float Rem case and died at build time with an unlocated internal error, "x86: that operator on f64". Dyn % on floats worked on both backends the whole time — flan_dyn.c's arith implements the fmod identity — so deleting the annotations made the program build again, which is exactly backwards. x86 is the dev loop's default backend, which is what turned a backend gap into a thing the author hit while writing ordinary code.

Fixed by calling the same function LLVM calls. There is no SSE remainder instruction and LLVM does not invent one: a frem that reaches the code generator becomes a call to fmod or fmodf, which objdump shows as a call to the PLT stub — fourteen of them in a build of the probe whose operands come through globals, and none at all in one written with float literals, where the pair is folded to its answer before any call exists. x86.ml now loads the two operands into xmm0 and xmm1 — already the SysV argument registers — and calls fmod or fmodf by width. Agreement is then by construction rather than by a second hand-written identity that would have to get every rounding, every signed zero and every infinity right on its own. Nothing new had to be arranged for the link: the prelude already declares both symbols as fmod-f32 and fmod-f64, and every link passes -lm.

Rem was the only gap. Walking emit.ml's prim arm against x86.ml's: the whole float surface is Add, Sub, Mul, Div, Rem and the six comparisons. x86 had four of the five arithmetic operators and all six comparisons, and the comparisons match LLVM's ordered predicates — oeq and one are built there from a setcc against ucomis plus the setnp that rules out the unordered case, which is what the o in the LLVM predicate means. The bitwise and shift arms are integer-only on both sides. So nothing else was missing.

The aspiration: tests that say x86 still tracks -O0

Wanted, and half of it exists. @x86 (test/dune:239) is already the diff: it builds every program in test/programs, spike/x86 and spike/js twice — once through LLVM, once through x86 — runs both, and compares stdout, stderr and the exit status. SURVEY_STRICT makes a DIFFER or a by-name refusal a failing build. So a corpus program that exercises a construct is already a test that the two backends agree about it, and the float % cases added to math3.flan are in that set by being in test/programs.

What @x86 does not do is pin the LLVM side at -O0. It builds both sides at the default -O2, so a construct LLVM folds at compile time — a % over two float literals is one: that build contains no fmod call — is compared as a constant against the x86 backend's actual lowering. The float % block in math3.flan goes through globals for that reason, the same reason arith.flan gives for its own. Two things would close the rest of the gap: an -O0 pass of the sweep, so the LLVM side emits the calls and branches rather than the answers — the script already has SURVEY_FLAGS, which hands the same extra flags to both sides, and both sides do accept -O0 — and something that walks the two prim match arms mechanically rather than relying on somebody reading them side by side, which is how this gap survived. Neither is queued.

Per-form initialisation semantics on re-run, decided 2026-09-20

The defining form is the contract, and the daemon does not have a policy about globals at all.

  • [defvar] is Common Lisp's [defvar]: its initialiser runs only if the variable is not already initialised. Its value therefore survives a re-run, which is what the daemon has always promised in its own words — "the globals are as the last run left them" — and what a zeroed one already got for free, since .bss is untouched by a second entry into main.
  • [defconst] with a compile-time-constant initialiser is written into the image — the linker's on one backend, [flan..init-data]'s stores on the other — and no startup code reaches it, so a re-run reaches neither. The split is [Tast.const_init]'s and it is over the initialiser, not over the form: a computed [defconst] would be guarded exactly like a computed [defvar]. The x86 backend does guard one; the LLVM backend refuses the program instead, because [Emit.const] has nowhere to run a computed value. That divergence predates the re-run rule — the refusal landed in 495629f and the flags in 931cf86 — and is noted here rather than fixed. Resolved 2026-09-20: there is no computed [defconst] any more, so the paragraph above describes a program the checker no longer accepts. See "A defconst is a compiler const" below.
  • If the language grows a [def]-style form that re-evaluates, that form recomputes on every run. None exists today and none was invented for this; the rule is written so that adding one is a new case and not a revision.

A re-run may therefore re-enter the startup function as freely as it re-enters anything else. Each initialiser guards itself: [Emit.startup_plan] gives every computed global a flag of its own — zeroed in .bss, set after the store — and wraps the store in a test of it. Per global rather than per startup function, because the rule belongs to the form; dev builds only, so a release build's .ll and .s are byte for byte what they were, which was measured on both backends rather than argued.

The flag's name is [.init~once.<global>]. It was [.init-once.<global>] until 2026-09-20, which a program could collide with: [.] and [-] are both ordinary symbol constituents, so [(defvar .init-once.x i64 7)] beside a computed [x] emitted the same symbol twice and the dev build died at the assembler on both backends — and worse, the flag's Bool was registered over the user's global in [Emit.globals], so the store to it came out as an [i1]. [~] is a terminator in the reader, so no symbol a program can write contains one; [destructure~N] uses the same trick. [test/programs/dev-rerun.flan] carries a global named [.init-once.counter] to keep it pinned.

Verified against a live daemon on both backends with [test/programs/dev-rerun.flan]: a computed i64 counts 41, 42, 43, 44 across four runs where it counted 41, 41, 41, 41 before; a computed dyn map keeps the mutations every run made to it; a zeroed [defvar] still accumulates; a [defconst] is untouched. The block in test_dev.ml that pins it fails on the pre-fix compiler in exactly the two computed cases and in neither of the other two, which is the other half of the claim.

The interaction with the park's root reset

Written when the watermark fix had not landed; both are merged now, so what holds is this. The park's [flan_dyn_root_reset] preserves the dyn globals' permanent roots — it cuts the stack back to [roots_base], the watermark [flan_dyn_root_globals_end] recorded. A re-run's [flan_dyn_root_globals_begin] empties the stack outright, the emitted main re-pushes every global's root, and [_end] re-records the base, so push-exactly-once holds via the bracket rather than via anything the guarded startup does. The guarded startup skipping its stores on a re-run is safe against all of that because the pushes take the global's slot address, never its value, and they sit before the startup call on both backends — nothing in the bracket depends on an initialiser having run.

bin/main.ml still spells the compile pipeline out by hand

The test directory's copies of Load → Check → Reach.link now go through [Test_support.linked] (test/test_support.ml). bin/main.ml has the same shape twice more — :684 and :864, each a load, a check and a [Reach.link] feeding [Build.executable] — and they were left alone, because the lane that did this was test/-scoped and because they are not quite the same three calls: the CLI loads through its own [load] and checks with [Check.program_all] rather than [Check.program]. So closing this is not a matter of calling the test module from bin/, which would be backwards anyway; it means the pipeline moving into lib/ — Build, or a small front-end module beside it — with the two checkers' difference made an argument, and bin/ and test_support.ml both calling that. Not queued.

Memory diagnostics on demand, decided 2026-09-20

The author's spec

Clojure's [*warn-on-boxed*] crossed with Rider's heap-allocation squiggles. Both kinds of allocation: the GC's — boxing a typed value into dyn where it is not an immediate, map/vec/string construction, the big-int spill — and the native side's — vec-new, a push that may grow, arena allocation, slurp, anything routing through an allocator. Two visually distinct classes, rendered in different colours by the editor and both FAINTER than an error ("they should somewhat fade"). Off by default, surfaced on demand two ways: an Emacs command of the "check for warnings" shape that asks for the current buffer's and overlays the answer, and a compiler flag so the CLI can decide when they appear. Flycheck integration a nice-to-have. Precision over completeness in v1: never mark a site that does not allocate — a dyn immediate must not squiggle — and a site that allocates only sometimes says "may allocate".

What landed

[Check.memory_sites], a pass over the finished program in the shape [Check.no_gc] already has: it runs after checking, answers a [Loc.diag list], and nothing downstream is told it exists. Asking cannot change what compiles. The class rides on the diagnostic's [kind] — "memory/gc" or "memory/native" — so the CLI and the daemon dispatch on one field and neither parses a message.

[flan check FILE warn-memory] and [flan build … warn-memory] print them to stderr in the standard [file:line:col: warning: ] shape with the squiggle, filtered to the file named on the command line. The exit status does not move.

[(:op "memory")] on the dev daemon answers [(LOC KIND MESSAGE)] rows over [t.session.program], needing no running program — a parked session answers it. [M-x flan-check-memory] in flan.el paints them: two faces, both fainter than [flan-error-face] and with no message drawn beside the line, priority under an error's so a refusal still wins a shared span. [M-x flan-clear-memory], or an edit, or asking again, takes them down.

Where this overrode the spec, and the evidence

(vec-new T) and (map-new K V) are not marked.

The spec's enumeration lists vec-new as a native allocation; the runtime says otherwise and the spec's own precision rule says to believe the runtime. The ["vec-new"] arm in check.ml passes a capacity of literal zero to [flan_vec_init], and that function's body returns before [flan_vec_grow] when [cap <= 0]. [flan_map_init] never takes a block at all and carries its own comment saying so — "No block until something is put in it". The block arrives at the first push or put, and those are the lines marked. [(vec-new dyn)] and a dyn map literal are the other answer: those are the dyn runtime's own objects and [gc_alloc] runs at the call, so they are marked.

The classifier reads [flan_vec_init]'s capacity argument rather than keying on the symbol, which is what lets [slurp] — the caller that sizes the Vec to the file — be marked "allocates" through the same entry point that vec-new is silent through.

Dyn arithmetic is not marked.

[flan_dyn_add] and its siblings end in [flan_dyn_from_i64], so a wide enough result does spill. Nothing static knows the operands, and a squiggle under every dyn [+] is exactly the false positive the precision rule exists to prevent. [flan_dyn_from_i64] IS marked at an explicit crossing, and only when the value can leave the 48-bit payload: a literal inside ±2^47 and a value widened from a narrower integer type are both provably immediate and silent.

Keywords are not marked.

Interned and immortal — flan_dyn.c's intern table holds the only copy of each name, nothing removes one, and [mark_value] walks BOX_OBJ and nothing else. There is no GC object to attribute.

Dyn push and put are not marked.

Added 2026-09-20, from a review: it had only ever been in a test comment. [flan_dyn_push] and [flan_dyn_map_set] are not in [Check.memory_class]'s table and they provably may allocate — a dyn vector or map growing itself is [gc_alloc] on the collector's heap, the same class every other gc row names. This is the one row the precision rule does not decide; it is a judgement. The unit this pass reports is a line the programmer can act on — crossing into dyn is a choice, pushing onto an allocator's Vec is a choice — and a dyn container taking a block to hold what was just put in it is the only thing it could do. Marking it would squiggle every (push dv x) in a program that chose dyn, which is the noise the rule exists to keep out. Pinned as a negative in test_flan.ml's "collected heap" row, beside the typed push that IS marked on the line above it.

The consequence is that the daemon's :note cannot claim "every site the checker can prove may allocate", and no longer does: lib/dev.ml's memory op says what holds and names this exception.

The overlays outlive the next command.

The spec asked for the paths that clear an error overlay. Those hang off [pre-command-hook], which takes an overlay down before the next keystroke — right for feedback about a failed evaluation, and fatal for an annotation: moving point through a marked line is what you do with these on screen. They clear on [after-change-functions] instead, plus the explicit command and the repeat-toggle. Documented in emacs/MANUAL.md.

Flycheck

flan.el has no flycheck wiring of any kind, so per the spec's own branch no checker was defined. emacs/MANUAL.md documents the CLI pattern and carries the [flycheck-define-checker] form for anyone who wants one — the flag is the command, and the printed shape is the error pattern.

Pinned

test/test_flan.ml pins three programs by exact location, kind and message: the collected heap (a dyn vec, a map literal, a string crossing, a wide i64, and the five immediates plus an i32 widening that must stay silent); the allocator side (arena-new, slurp's sized vec-init, a typed container's view record, put, reserve, with a typed vec-new and map-new silent between them); and dyn arithmetic answering nothing at all. test/test_dev.ml drives [(:op "memory")] over the socket against programs/dev-dyn-global.flan, whose one line is two gc crossings at two columns and no native allocation anywhere.

Review-batch findings, 2026-09-20

Two things found in review that this lane could not fix in the files it owned. Written down here so they are not lost with the branch.

The daemon leaves its temp directory behind, forever

Every session makes /tmp/flan-dev-<pid>/ — lib/dev.ml:3516 for the two-process daemon and lib/dev.ml:4415 for the merged one — and nothing ever removes it. It holds the built program, the host's host.ll or host.s, the reload modules and agent.sock: about 7MB a session. The review counted 873 of them, 5GB, on the morning of 2026-09-19; this lane counted 68 and 457MB on 2026-09-20. Whatever removed the difference, nothing in the tree did, and the count climbs again with every M-x flan.

The fix is small and the merged daemon already has the one place for it. Its session ends at lib/dev.ml:4395: [accept_loop] returns, the listening socket closes, the editor socket is unlinked, and [Unix._exit 0] follows. A recursive remove of [dir] belongs between the unlink and the flush — that one site covers all three ways a session ends cleanly, because all three come back through [accept_loop]:

  • close from the editor (lib/dev.ml:3359, which returns [true] and ends the loop);
  • no editor connected for the grace period (lib/dev.ml:3472);
  • the program finished and the parked process is let go.

The two-process daemon needs the same thing at its own session end.

Two deliberate non-goals, and they are the reason this is worth spelling out rather than just doing:

  • Not on a crash. The sibling branch at lib/dev.ml:4393 is [accept_loop] raising, and the directory is the post-mortem — the binary and the exact IR it was built from. Only the clean return cleans up.
  • Not other sessions' directories. A sweep of /tmp/flan-dev-* would delete the working directory of a daemon that is still running, and a stale pid is not proof of anything. Each session removes its own and no more.

Not done here because lib/dev.ml belongs to another lane that has not merged.

and's last operand gets a misdirected caret in a want-free position

[shortcircuit] in lib/parse.ml documents this at the site; the summary is that (println (and true true (vec-new i32))) reports "expected (Vec i32), found bool" with the caret on the second true. The last operand of an and is the then arm, check_if types the then arm first, and the mismatch is therefore reported against the else arm, which carries the previous operand's loc. Every other operand position is right, because an operand anywhere but last is a condition and check_truthy blames it at its own loc; or is right everywhere, because there the chain and not the sentinel sits in the else arm. Compiled on the tree at every operand position of both forms, want-free and want-ful; want-ful is right everywhere too, because the want reaches each arm instead of the arms being unified against each other.

Not fixed. Three candidate fixes were considered and rejected: giving the else arm the last operand's loc makes the sentence read backwards ("expected (Vec i32)" under a caret on the thing that is the (Vec i32)); answering a bool sentinel again reverts the fix that made (or nil "x") answer "x"; and inverting the condition to move the last operand into the else arm costs a [not] per operand and worse locs than it buys. What would fix it is check_if preferring the arm that is not a compiler temp when it decides which one to blame — a change in check.ml, which this lane did not own.

A numeric cast opens a dyn box, decided 2026-09-20

Every numeric cast — (f64 x), (i64 x), (u32 x), (f32 x), all of them — takes a dyn operand now. Until this, the cast arm refused it with "f64 converts a number, found dyn", and the only thing in the language that opened a box was a typed parameter, so a program wanting a number out of a dyn wrote a one-line function whose parameter slot did the unboxing and called that. A cast is the operator for "convert this to that"; it is the spelling that should have worked.

Three cases, and the middle one is the author's call.

  1. Same kind. The box holds what the cast asks for, so the cast is the unbox and nothing else. A dyn box only ever holds an i64, an f64 or a bool among the numbers, so (f32 d) on a float box unboxes to f64 and narrows, and (u32 d) on an int box unboxes to i64 and narrows — each by the rule the same cast already follows on a typed operand.
  2. Cross kind — COERCE, with a warning. The author's words were "just coerce it with a warning". (f64 int-box) is 7 -> 7.0 and (i64 float-box) is 2.5 -> 2, the truncation toward zero (i64 2.5) already does, range-check and ArithError included. This overrides the tempting rule of matching the parameter boundary, which traps on a kind mismatch: a cast is already a conversion operator — (f64 5) converts a typed integer — so converting across the box is the cast doing its job. The warning exists because the box's kind was not what the program apparently expected, not because the conversion is in doubt.
  3. A box holding a non-number traps: text, nil, keyword, vec, map — and bool, which is not a special case but the parameter boundary's existing answer mirrored. flan_dyn_need_i64 refuses a dyn holding true at a typed i64 parameter today, and (i64 d) refuses it for the same reason and in the same voice.

The warning is once per SITE

These casts sit in per-cell-per-frame loops — sand.flan runs at 120fps — so a per-occurrence line is a flood and not a diagnostic. check.ml threads the site's loc text into the runtime call and flan_dyn.c keeps a small table of sites it has already spoken about, keyed on the loc's bytes rather than its address: the two backends emit their own constants for it and neither promises that two mentions of one site share a pointer. Sixty-four sites, and past that it stops deduplicating rather than stops warning — the noisy failure, not the silent one. The line is:

FILE:LINE:COL: (f64 x) found a dyn holding an int, and converted it to f64 — warned once for this site

The bare FILE:LINE:COL: is the house shape for a loc-bearing runtime diagnostic — flan_rt.c's bounds, divide-by-zero and null-allocator sentences all open that way, and the flan: prefix is reserved for the lines that carry no location (the leak report at flan_dev.c:1765, the argv failure at flan_rt.c:179). An earlier draft of this warning wore flan in front of the location; it was taken off to match the neighbours.

Both builds warn, dev and release. No precedent was found making a diagnostic of this kind dev-only: the allocation registry's notes are the one runtime family a release build drops, and those are a feature being disabled, not a warning being hushed. After the first hit this costs a tag compare and a linear scan of a handful of entries, which is nothing.

How it lowers, and why that shape

check.ml's [cast_dyn] builds a branch, not a call that converts:

(let ([s d]) (if (= (flan_dyn_cast_kind s "file:1:2" "u32" 0) 1) (u32 (flan_dyn_need_f64 s)) (u32 (flan_dyn_need_i64 s))))

flan_dyn_cast_kind answers 1 for a float box and 0 for an int box, traps for everything else, and warns when the answer disagrees with the target. Each arm is then an ordinary [Cast] over an ordinary need — the same node a typed operand of that type would have produced.

The alternative was a coercing runtime entry point answering the finished number, and it was rejected because (i64 2.5) is not a bare fptosi in this compiler: Emit.check_cast range-checks it and signals ArithError when the value will not fit, and lib/x86.ml does the same. A C function returning an int64_t would have had to grow its own second opinion about range and NaN, in a second place, for two backends — a fork of exactly the kind "Arithmetic semantics do not fork across the two spaces" forbids. With the branch there is nothing to keep in step, and "x86 tracks LLVM -O0" holds by construction: programs/dyn-cast.flan prints byte-identical output on both backends, warnings and trap included.

The generic cast arm — (t x) inside a body with {:where (numeric? $t)} — did NOT grow a dyn case and did not need one: the operand's type there is what the bound admits, and numeric? does not admit dyn, so a dyn cannot reach that arm. Pinned in test_flan.ml.

What this repeals

test_flan.ml's row "a keyword with no expectation converts as dyn" pinned (i64 :space) as a check error. It is a well-typed program now and a run-time trap instead; the row became an [accepts] saying so. That is the whole of the behaviour change outside the new feature.

For the author: the shims in sand.flan can go

sand.flan defines dyn->f64 and dyn->u32, one-line functions whose only job is that their parameter slot unboxes. Every call site can now write the cast directly — (f64 d), (u32 d) — and the two defns deleted. Not done here: sand.flan is the author's WIP and this lane did not touch it.

Follow-ups from the 2026-09-20 reviews

Small, verified findings the reviews turned up after their lanes had landed. Each was re-checked against the tree before it was written or fixed.

runtime/flan_dyn_stub.c is dead, and the author should decide its fate

RECOMMENDATION: delete it. Not done here — it is a file the author added and removing it is his call, so the facts are written down instead.

What it was for, in its own words

Its header says it plainly: "a standing-in implementation of the flan_dyn.h ABI … THE MERGE REPLACES THIS FILE WITH runtime/flan_dyn.c. It exists so that the compiler side of dynamic-by-default can be built and run against the fixed ABI before the real runtime lands." The merge it names happened. The real runtime is runtime/flan_dyn.c, and lib/dune pastes that file — not this one — into Runtime_src (lib/dune:41, :52).

Why it is dead

  • No dune rule mentions it, in lib/dune, runtime/ or test/dune.
  • No .ml refers to it; no test links it; flan build never compiles it.
  • The only mentions anywhere are two historical citations in docs — docs/SPIKE-DUPLICITY.md:58 cites a line number in it, and docs/handoffs/HANDOFF-dyn-m1.md:131 explains that the stub verified nothing about root discipline. Both are narrative about a period that has ended; neither gives the file a live job.

It does not compile

Two conflicting-type errors against its own header, both pre-existing and neither caught by anything, because nothing builds it:

clang -c runtime/flan_dyn_stub.c -Iruntime flan_dyn_stub.c:90: flan_dyn flan_dyn_from_bool(int32_t v) vs flan_dyn.h:68: flan_dyn flan_dyn_from_bool(uint8_t b); flan_dyn_stub.c:293: int32_t flan_dyn_need_bool(flan_dyn v) vs flan_dyn.h:145: uint8_t flan_dyn_need_bool(flan_dyn v);

So the one thing it could still be — a second implementation the header is diffed against — is a thing it has already stopped being.

The cost of keeping it

It is maintained by accident: the dyn-cast lane added [flan_dyn_cast_kind] to it (flan_dyn_stub.c:333) alongside the real one. That is a duplicity the doctrine does not ask for — the same side of the same capability, written twice — and the copy is the one no test can reach. This batch deliberately did NOT carry the warning-prefix change below into it, so the two now disagree.

The cast warning wears the house prefix

See "The warning is once per SITE" above. The flan in front of the location came off; a loc-bearing runtime diagnostic opens with a bare FILE:LINE:COL: everywhere else in the runtime.

Where the memory classifier overrode the spec: dyn push and put

Written into the memory-diagnostics decision above, where its siblings live: see "Dyn push and put are not marked" under "Where this overrode the spec, and the evidence". It had only ever been in a test comment.

Two stale "kept honest by" claims, corrected

lib/build.ml's note beside the header write and test/dyn_ops.c's own header both said dyn_ops.c calls every function runtime/flan_dyn.h declares. It does not, and the corrected header made that visible: [flan_dyn_cast_kind], [flan_dyn_is_nil], [flan_dyn_need_not_nil], [flan_dyn_map_get], [flan_dyn_map_set] and [flan_dyn_map_contains] are declared and never called there. The check the include buys is real but narrower than the claim: for a function dyn_ops.c calls, the call is compiled against the header and the symbol has to resolve against flan_dyn.o, so a rename, a removal or a changed argument list is a compile or link error in dune test. A function nothing here calls gets neither. Both comments now say that instead.

Typed structs do not version; a shape that evolves is a defclass, decided 2026-09-20

The struct-version-word design (plan.org's dev/release table, candidate B in docs/SBCL-REDEFINITION-NOTES.md) is dropped, not deferred. A typed struct redefinition that changes layout keeps today's refusal; the author's call — "let's just ignore it then, we should be using defclass instead." The division of labour is the two-model one: a shape still being discovered lives on the dyn side as a defclass, where CLOS-style lazy migration handles redefinition (its own lane); a typed defstruct is a commitment to a layout, and changing a commitment restarts the process. SBCL context that settled it: SBCL also refuses by default (a continuable error), and its push-through-and-invalidate behaviour is cheap only because its instances carry headers, which Flan's flat structs deliberately do not.

A defconst is a compiler const, decided 2026-09-20

The author's words: "defconst should not be computed, it's the equivalent to a compiler const." So a defconst's initialiser has to be a compile-time constant, and the refusal is the checker's — [Check.const_defconst_init], called from [check_global] right after the union refusal it sits beside.

It had to move because the two backends were not refusing the same program. [Emit.const] refused a computed defconst by name, late and on its way to LLVM IR; the x86 backend classified globals by [Tast.const_init] alone, so the same defconst fell into the computed set, was stored by the startup function and was guarded by an [.init~once.] flag exactly like a defvar. The same split let x86 accept (defconst g U (U.B {.x 1})), a data type case in a constant, which LLVM refused with the byte-level-encoder message. One refusal in the checker ends both, and it is the only place that can name the way through.

The boundary, derived rather than chosen

What a defconst may be is exactly what [Emit.const] can write and what the x86 backend's [data_sym] path lowers, which is [Tast.const_init]'s set: an integer, float, bool or string literal; unit; a zeroed or uninit value; [None]; a [Some] of one of these; and a struct literal or array of them. Nothing that compiled on LLVM before stopped compiling.

Integer arithmetic is in the set and is not an exception to it. [collect]'s folding pass — [const_int], +, -, *, / and %, over literals and over other folded constants, to a fixpoint — has already replaced (/ screen-height cell-size) with its answer before [check_global] looks at the initialiser, so what the refusal sees is an [Int]. That pass is integers only, which is why (defconst half f64 (/ 1.0 2.0)) is computed and refused. Widening it would be a second folder and was not done; a test pins that there is not one.

What came out

  • [Emit.const]'s two refusals are gone. Both are the checker's now. The data type case one is word for word what it was. The general one gained what the checker knows and the emitter did not: it names the constant — "the constant c is computed" rather than "this one is computed" — and it spells the way through, (defvar c ...) or a literal, with the integer arithmetic the folding pass accepts named beside it. Both are located at the declaration now rather than at the expression inside it, which is where every other refusal about a global points and what [next-error] jumps to. What is left in the emitter is a [failwith] in the file's own idiom: no program reaches it, and it fires only if the checker's accepted set and [Tast.const_init] ever stop agreeing.
  • The case is searched for through the aggregates, which is what [Emit.const] did by recursing: (defconst g S (S {.u (U.B {.x 1})})) is a case the image cannot hold just as much as a bare one, and "this is computed" would be advice nobody could act on. Left to right and first offender wins, so a computed field written before a case field still gets the general message — the emitter's own order, since it spelled the fields in order and failed at the first one it could not spell.
  • [emit_global]'s gconst || const_init ginit lost its left half. The form no longer decides anything there; the initialiser does, and a zeroinitializer is now always a defvar waiting for the startup function.
  • x86 needed no edit: it never had a defconst case to delete. It classifies by [Tast.const_init], and the checker now guarantees a defconst passes it, so no defconst reaches [Emit.startup_plan] and no [.init~once.] flag is made for one. The flag machinery for defvar is untouched.

The test harness that depended on the old rule

test_flan.ml's [infers] read a type off (defconst probe <expr>), which is how it pinned literal defaulting and every primitive's result — and most of those probes are calls. It asks [Check.expression] now, the way a session checks an expression sent from the editor, which is the question the wrapper was only a way of asking. A defvar could not stand in: only the defconst form takes no type. The one corpus row that relied on an untyped computed defconst, (defconst k (g)) ordered before [g], is a typed defvar and still pins the order-independence it was there for.

The third element of a defvar decides, 2026-09-20

The author, deciding it: "if it's 3 atoms then it's dyn", and "dispatch the if it's a type do the right thing."

So (defvar x <type>) is the zeroed static global it has always been and (defvar x <expr>) is a dyn global initialised from that expression at startup. (defvar current-color i32), (defvar grid [rows [cols u32]]) and (defvar p Point) all keep their meaning to the letter; (defvar score 0) is a dyn holding 0, and (defvar game-data (edn/read-file "x.edn")) is what (defvar game-data dyn (edn/read-file "x.edn")) spells out. The four-element forms are untouched, (defvar x dyn <expr>) among them.

This is a step in the direction the "dynamic-first dream" names — the author wants dynamic by default, lowering to static where it can — and it is the cheapest one available: the dyn spelling stops needing a keyword, and the static spelling loses nothing. It is the same dispatch the parameter vector already makes ([(defn f [x y] …)] is one annotated parameter if [y] names a type and two dyn parameters if it does not), now in the one other position where a name could be either.

Where it is decided

Half in [Parse.defvar3] and half in [Check.settle_defvars], split by what each one can know.

Parse settles every form a shape settles, and that is most of them: [0], a string, a map, /JosephFerano/flan/src/commit/8d49d0dddc6dc5f0189678901764637babfc9efc/1%202%203 and [(f "x")] are not types by any reading, so the global is dyn and the third element is its initialiser; /JosephFerano/flan/src/commit/8d49d0dddc6dc5f0189678901764637babfc9efc/4%20u32, [()] and [(Fn [i32] i32)] are types by any reading and keep today's meaning. Note where the bracket falls — /JosephFerano/flan/src/commit/8d49d0dddc6dc5f0189678901764637babfc9efc/n%20T stays a fixed array, so no [(defvar rows [4 u32])] changed under this — and that [texpr] is called under a handler, because "does this parse as a type" is a question its refusals answer.

Two shapes are left, and a name and not a shape decides them: a bare symbol, and [(head arg …)] with type-shaped arguments. Both readings leave Parse together — the [texpr] in the [Ast.Defvar] and an [Ast.Ambiguous] expression beside it — and [Check.collect] picks, at the point where every type name is registered and just after [pair_decls], which is there for the same reason. The type reading wins wherever there is one, and a built-in constructor is recognised by name rather than by whether [resolve] happened to accept it, so [(Vec i32 i32)] stays a malformed [Vec] instead of becoming a call to something named [Vec].

An undecided defvar leaves [collect] as a [Zeroed] or as an [Init] at [dyn] — that is, as [(defvar x dyn <expr>)] exactly. Nothing downstream has a third case to learn: the startup lifting, the [.init~once.] re-run guard and the collector root are the ones that form already had, and neither backend was touched.

The ambiguous symbol

One namespace covers every declaration kind ([collect]'s [claimed] table), so a type and a value cannot share a name and the two readings can never both be live. What the rule does create is a symbol that is neither, where the old "unknown type foo" would now send a reader looking for the wrong mistake:

foo is neither a type nor a value, and the third element of a defvar has to
be one or the other: a type there declares a zeroed global of that type —
(defvar total i64) — and a value there declares a dyn global holding it —
(defvar total 0). Nothing named foo is declared as either — did you mean fo?

Both readings, both spellings, and the near miss ranges over the value names as well as the type names — [near_miss] grew an [~also] parameter for it, and this is its only caller.

Pinned

test_flan.ml holds the four spellings with their meanings (the type and whether anything runs at startup, not merely that they compile), the parse shapes, the collision refusal and the diagnostic verbatim; test/programs/defvar-dyn.flan is both readings in one program, pinned in acceptance at the default, -O0 and x86; and dev-rerun.flan grew a [(defvar tally 0)] whose line is 4 after three re-runs, which is the claim that the new spelling goes through the old guard.

Classes and generic functions, 2026-09-20 — M2 queue item 6

The recorded decision was "defclass = named dyn map + shape tag; CLOS class dispatch AND Clojure-style arbitrary dispatch functions", and it is built as written. The two dispatch styles are one mechanism and not two: a class dispatcher is the shape tag of the first argument used as the dispatch function, so a method written for the class point and one written for the value :point are the same branch — which is also why the two spellings are refused as duplicates of each other.

The surface, as landed

(defclass point [x y])          ; a class: named slots, no types
(point 3 4)                     ; the constructor — the class's own name
(class-of p)                    ; :point, and nil for anything else
(get p :x) (put p :x 10)        ; the slots are map keys; nothing new

(defgeneric area [self] dyn)               ; CLOS: dispatch on the class
(defmethod area point [p] (* (get p :x) (get p :y)))

(defmulti describe [x] dyn (get x :kind))  ; Clojure: the body is the dispatch
(defmethod describe :square [s] (get s :side))
(defmethod describe :else [s] "something else")
  • A slot is a key. An instance is a dyn map, so get, put, has-key? and len are how one is read and written, and no operation was added for any of it. (len p) is the slot count.
  • The constructor is positional, one argument per slot in the order they were written, and it is an ordinary defn — so its arity refusal, its cell in a dev build and its behaviour under redefinition are the ones every function already has. The named-slot spelling is deferred; see below.
  • A method has no return slot. The generic states the return type once, for every method written for it, which is also what makes the parse unambiguous: the vector is always the third form.
  • Every parameter of a generic and of a method is dyn, written or not, and a slot that is not a bare name is refused. That keeps these forms off the undecided-pairing path a defn's vector is on: a vector that may hold only names can be read by the parser, where a defn's cannot be read until every type name is known.
  • A dispatch value is a literal — a class's name, a keyword, a string, an integer, true, false, or :else for the arm everything falls through to. :else and not Clojure's :default, because match already spells "none of the above" that way and two words for it would be one too many. :else is the last arm whatever order it was written in.
  • A miss signals. (defstruct NoMethod [generic string value dyn]) in the prelude, signalled with error, carrying the name written at the generic and the value the dispatch actually produced. A condition and not a trap, because a miss is something a program can be written to answer; handler-case around the call is the shape, and a :else method is the other answer. No restart is established at the miss, which is BoundsError's decision taken for BoundsError's reason. value is the first dyn field in any condition here; the per-type descriptor an item-2 struct carries is what the collector reaches it by.

The shape tag: a header field, not a reserved key

This queue item's own note said "named dyn map + shape tag", and the obvious reading was a reserved entry in the map. It is a field in the object's header instead — an interned kw_entry * in the map arm of flan_obj's union — and the departure is deliberate.

An entry would be counted by len, walked by both renderers, and compared by dyn_equal's key loop. Every instance would answer a length one larger than its slot count, print a key nobody wrote, and be one put away from having its own class changed. A header field cannot be reached by get or put at all, so the question of a user key colliding with it does not arise rather than being answered by picking an unlikely spelling.

It cost nothing. The view arm of that union is 24 bytes, so the map arm growing from 16 to 24 does not grow the union, and sizeof(flan_obj) is 48 before and after — checked, not assumed. It needs no marking either: an interned keyword entry is immortal by construction and is not a collector object, which mark_value states by following BOX_OBJ and nothing else.

The tag is read in exactly four places in flan_dyn.c: class-of answers it; dyn_equal compares it, so two instances of one class compare by their slots and an instance is never equal to a plain map with the same entries (Clojure's answer for a record beside a map); and both renderers write it — render, which is what print goes through, and say_render, the 96-byte sentence a trap prints, so a dyn trap naming an instance says which class it was. The spelling is #point{ :x 1 :y 2}, Clojure's own for a record.

The tag is built from the class's qualified name, and the qualifier is the importer's alias rather than anything the defining package chose — the same class imported as a and as zz tags its instances :a/point and :zz/point. That falls straight out of [Load]'s rename, and it is right for the dispatch, which resolves the class name through the same rename and therefore agrees with it. What it is not safe for is a hand-written dispatch value: (defmethod g :a/point ...) is a keyword and nobody qualifies it, so it is coupled to one import's alias and silently answers for nothing under another. Write the class's name, (defmethod g point ...), which is renamed with everything else. Two packages' own point classes are two classes either way, which was the property wanted.

How it is built: a pass, not a macro

None of the four forms reaches the checker. lib/classes.ml rewrites the whole declaration list at the top of Check.build_program, exactly where Shim.expand rewrites a declare-c: a class becomes its constructor, a generic becomes one function whose body binds the dispatch value and compares it down a chain, and a method becomes a branch of that chain.

A macro sees one form and this needs the whole list, because a method may be written above its generic, below it, or arrive at a reload an hour later. Running over the flat list is also what makes the dev loop work: a reload rebuilds every dispatch from the session's whole set of declarations.

The method bodies are inlined rather than lifted into functions of their own, and that is the load-bearing choice. A generic is then exactly one top-level name, so adding a method to a running program is the ordinary redefinition of one function, through the cell the call site already goes through. session.ml names the generic alongside the method's own declaration name for that reason — without it a C-c C-c on a defmethod would install something no call site reads. A method still declares a name of its own, area@:circle, which is what makes re-evaluating one a replacement and evaluating a new one an append; no function is emitted under it. Proved end to end against a real daemon (test_dev.ml, "a method added to a running program"), not only at the session's report.

A method's own parameter names are bound from the generic's in parallel, through temporaries in the unspellable [~] namespace. A [let] binds in sequence, so the pairwise spelling reads a name it has just bound: a method /JosephFerano/flan/src/commit/8d49d0dddc6dc5f0189678901764637babfc9efc/b%20a under a generic /JosephFerano/flan/src/commit/8d49d0dddc6dc5f0189678901764637babfc9efc/a%20b would be handed its first argument twice and the second would be unreachable. Both the swap and the one-step shift are pinned in the survey program, where the values are what is wrong rather than the types.

The cost, recorded rather than hidden: a method is not separately callable and is not a frame of its own. A break loop under a method shows the generic. And the generic's own parameter names stay in scope inside a method that renamed them, so a body reaching for self where it declared p silently resolves instead of being refused — small, and closing it would mean giving the dispatcher unspellable parameter names, which is what the inspector reads.

Deferred, each with the reason

  • Inheritance. plan.org's own rule is that method specificity and ambiguity rules are required before inheritance or multiple dispatch is enabled, and with single dispatch on literal values there is no specificity question at all: two methods either answer for the same value, which is refused, or for different ones. A hierarchy would create the question, and the author never asked for one.
  • Multi-argument dispatch. plan.org names it as the later extension, for (collide Player Enemy). It wants the specificity rules above.
  • :before, :after, :around and call-next-method. They only mean something once methods can be ordered by anything but equality, which is the same gate inheritance is behind.
  • Named-slot construction, (point {:x 1}) with an omitted slot meaning nil — the dyn twin of (Cursor {.src s}) with its omitted-is-zeroed rule. Positional is what a generated defn gives for free, arity included; the named form is a checker special case and was not worth one at v1.
  • Unknown-slot checking at (get p :z). The one compile-time win a declared slot set makes possible (docs/SPIKE-DUPLICITY.md §8 names it), and it needs the checker to know the class of an expression — class-typed tracking on the dyn side, which dyn deliberately does not have. A class adds a tag and a dispatch, not a static slot discipline.
  • Computed dispatch values. Clojure registers a method under any value because registration there is a run-time call; here it is compile-time, and the method's declaration name is built from the value.
  • nil and floats as dispatch values. :else covers the nil case, which is the common one (class-of answers nil for anything that is not an instance); a float compared for equality is a trap waiting to be sprung.
  • Class redefinition and migration — plan.org's redefine-class / migrate-instances. A heterogeneous map has no layout to be stale, so nothing breaks today when a class gains a slot: old instances simply lack it. Enumerating live instances is the part that is missing, and it is the pool's question rather than this lane's. Built after all, and without the enumeration: see "Lazy instance migration" below, where the answer turned out to be CLHS 4.3.6's — do not walk the heap, stamp the instances and migrate each one when it is next touched.
  • The JS backend. It refuses dyn wholesale, so none of this compiles there. Same parking as the string-equality hole above.

Two things found on the way, neither about classes

  • The x86 backend's redefinition module never emitted the per-type dyn descriptors. Emit.redefinition has always emitted them, by going through finish; the x86 twin ended at the rodata section and stopped. Nothing had reached it, because a redefined body had to construct a struct holding a dyn to need one, and until NoMethod there was no such struct a compiler-written body could build. It is not a bad read at run time — a descriptor label is local, so ld refuses the module with an undefined symbol. Fixed with one line beside the same call in the executable path. Emitting them turned up the second half: descriptors_asm wrote them into .rodata, and a descriptor holds the address of its own offset table. A relocation in a read-only section is a DT_TEXTREL — ld warns about it in a PIE and refuses it in a shared object — so the section is now .data.rel.ro, which exists for exactly this and is what both the executable and the reload module use. Verified with readelf -d on a reload module from each backend: no TEXTREL, descriptors in .data.rel.ro. Still unexercised, and for the next sweep rather than this lane: marking THROUGH a descriptor that an x86 reload module emitted. What is proved is that the module links and runs; what is not is a collection happening while a live instance of a dyn-holding struct sits in a frame of a body that module delivered. The LLVM path has been exercised since item 2; this one has existed for a day.
  • A dyn value answered by eval-expr never reaches the reply's :value. It renders to the program's own stdout, which arrives on a later reply's :output — the dyn-global rows already read one that way and say so, and (+ 2 3) answering "5" beside (area (point 3 4)) answering "" is the whole of the difference. Left standing: where a dyn expression's value should surface is a question about the editor protocol, not about this lane. The dev test works around it by comparing inside the expression, so what crosses the wire is a typed 1.

What was run

dune test --root . green (exit 0, no FAIL lines) after each commit, and again on the rebase onto the defvar-dyn lane — whose load.ml arms are the defvar one and whose ast.ml arm is the Ambiguous initialiser, disjoint from the four class arms beside them; both sets were read against each other by hand rather than trusted to the auto-merge. Three acceptance rows for test/programs/dyn-class.flan — default, -O0 and --x86 — and a three-way diff of the program's real output across the same three, captured by hand before the rows were written and again after the rebase. It is in test_sanitize.ml's list; per the sweep policy the sweep itself was not run.

Found while running it: dune test exits 1 at random, and has since before

this lane — FIXED test_dev.ml's trap_park rows are racy, and when they lose the race the whole test binary dies with Fatal error: exception Flan.Wire.Closed — exit 1 with no FAIL line anywhere, which is the worst shape a failure can have given that the sweep policy says a lane is judged on the exit status.

The mechanism: trap_park polls with ask, which is a bare Wire.send / Wire.recv pair with nothing around it, and the program it is polling has just aborted at the break loop. If the daemon exits between the send and the recv, Wire.recv raises Closed, nothing catches it, and every row after it — in this lane's case the new class daemon among them — never runs. Both observed failures landed at the same row, dev-trap-null-alloc.

It is not this lane's. Measured on a detached worktree at dev-loop's tip (c4e0725) with nothing of this lane in it: 2 of 5 runs exit 1 with the same exception at the same row, against 2 of 5 on this branch. The rates match because the code is the same.

Not fixed here, deliberately: the fix is to catch Closed in that poll and read it as the program having ended, which is a claim about what those rows mean and belongs to whoever owns them. Flagged rather than patched.

Two struct spellings, 2026-09-20

Both were DISCUSS.org items, both diagnosed there as the same parse/check boundary problem, and both are decided by the author on 2026-09-20.

A. A bare {.field v} takes its type from the position it stands in

The note's own diagnosis was right: the refusal ("a bare map is not an expression; write (Type {.field v})") sat in Parse.expr, before any checking, so (defn get-mouse-cell [] Cell ... {.row r .col c}) could not work no matter what the checker knew. It has moved.

Parse now builds Ast.Bare — a field list with no name — out of the same struct_fields the named form uses, so the two field lists are parsed by one function and cannot drift. Check.check_bare reads the type name off the expectation and hands that same list to check_struct. That is the whole feature: ZII for an omitted field, the unknown-field refusal, the duplicate-field refusal, their notes and their error kinds are not "the same as" the named form's, they are the named form's, reached by the same call.

Accepted — every position that carries a want:

  • a defn's return position (the case from the notes),
  • an argument of a call, the only argument or a later one,
  • a field of an enclosing literal, at any depth,
  • a typed place being set, a local or a field of one,
  • a union want, which reaches check_union by the same route.

Refused, at checking, by name:

  • no want at all (a let binding, a body form that is not the last): "does not say which struct it builds — the fields alone do not name a type", naming both ways out, and saying why a let binding is not one of them (a local takes its type from its value).
  • a dyn want: refused, and told that a dyn map's keys are keywords. This is the boundary that mattered most. Braces at a dyn want are the dyn map literal and stay exactly that; a .field-keyed brace was never part of that spelling and is not being quietly given a second meaning now.
  • a want that is not a struct type at all: "i32 is expected here, which is not a struct type".
  • a data type's name as the want: inherits the existing message, which names the cases — D is not specific enough, a value of D is one of its cases.

One thing had to move in Check as well as in Parse. (g {.row 2}) is parsed as a struct literal named g, because the parser's struct-literal arm fires on the shape of the single argument and has no table to consult; it used to be refused with "g is a function, not a struct". Now, when the head is a name that would actually resolve to a callee (a defn, a generic, or a local of function type — callable), it is handed back to named_call as the call it was written as, with the fields rebuilt into the Ast.Bare node the parser would have made anywhere else. An unknown name keeps the old "unknown struct" report, because that shape is usually a misspelled struct name. (name {}) keeps its old refusal untouched: the empty braces are genuinely ambiguous between the zero-field struct literal and the empty dyn map, and that one does have the let-binding fix its message already names.

B. (Cell 1 2) positional, and arity is exact

The note's diagnosis again: the parser cannot tell (Cell 1 2) from any other call, so Check does it. The decision sits on the last arm of named_call — after a local of function type, after a generic, after the global function table — where the old "Cell is a type" refusal used to be.

No collision is possible. collect's claimed table spans every declaration kind, so one name is one declaration and a defstruct Cell beside a defn Cell is "Cell is defined twice" before any of this is reached. A (defclass point [x y]) constructor is a real defn that Classes.expand wrote before checking began, so (point 1 2) resolves in env.fns two arms above the struct one and never reaches it. Pinned both ways.

The arity decision: exact, no partial ZII

Positional construction gives every field or it is refused. This is not a retreat from ZII — ZII is what the designated form does, and (Cell {.row 1}) still zeroes .col, which is where the message points. The reason is that a positional list cannot say which field it left out. (Cell 1) reads as a Cell with one field given, and which field that is depends on a declaration order the author is free to change later; a trailing field silently zeroed there is the field-reorder hazard at its very worst, arriving as a wrong value rather than as an error. So a short list is a refusal that names the first field it did not reach:

Cell has 2 fields and 1 was given positionally — .col has no value.
Positional construction gives every field, in declaration order; to give
some of them and zero the rest, a struct value is written
(Cell {.field value ...})

with declared_note pointing at the declaration. A long list points at the first extra argument and shows both spellings. Odin's positional literal takes the same line, and the repo's ZII philosophy is not against this: ZII is about what an omitted field means, and this refusal is about a spelling that cannot express omission at all.

The field-reorder hazard, accepted

The author's words, on B's remaining cost: "B can be fixed with refactorings later on when we decide to add it." Reordering a defstruct's fields silently changes what every positional call site builds, and nothing in the compiler catches it when the types happen to line up. Accepted as the price, with refactoring tooling named as the eventual answer rather than a compiler rule.

Argument type errors

Each argument is checked against its own field's type by map2_lr and ~want~, exactly as a call's arguments are checked against its parameters — so the mismatch is reported at the argument, in the words a call's argument already gets ("expected f32, found string"). What is added is a note, "this is Cell's field .col", plus declared_note, because a positional call site is the one place the source does not show the field name. The note is attached only to a diagnostic raised at that argument's own location, and it only ever names the position — which is true whatever went wrong there — so a nested failure inside the argument cannot be miscaptioned by it. Naming the field in the message would need an error-context helper loc.ml does not have; not built here, since the note carries the same information and adding the helper touches a file the Elm-messages lane is in.

Backends

Zero edits, and nothing to edit. Both features are gone by the time Check finishes: a bare literal becomes the Tast.Make the named form already built, and a positional call becomes that same node with its arguments put into declaration order. Emit and X86 have no case for either. The three acceptance rows for test/programs/struct-ergonomics.flan — default, -O0, --x86 — print the same fifteen lines, which is what says so out loud.

One existing test's needle had to change, and one did not

test_flan.ml's two "bare struct-shaped braces still refuse" rows refused at parse and now refuse at checking, for a different and better reason; their needles and the comment above them were rewritten together. The row for (Cursor {:src s}) did not have to move: that form is now a Cursor built from too few arguments, and the new refusal still contains "a struct value is written (Cursor {.field value …})", which is what its needle asks for. Its comment was corrected anyway, since the reason it refuses changed.

What was run

dune test --root . in the lane's worktree: exit 0, no FAIL lines. The first run exited 1 with Fatal error: exception Flan.Wire.Closed and no FAIL line anywhere — the known test_dev.ml trap_park race recorded under the classes lane above, not this lane's; the rerun was clean. The survey program was run by hand at all three settings and its output diffed across them before the acceptance rows were written. Not added to test_sanitize.ml: the program allocates one small dyn map and nothing else, so there is nothing for ASan to find that dyn-map.flan does not already exercise.

Six dogfooding items off DISCUSS.org, 2026-09-20

Each of these is an author note from a session of writing Flan rather than a report from a test. They are small and they are unrelated to each other, which is why they went in one lane: every one of them is a place the language said no for no reason, or did not have a name it should have had.

(when test) with no body, and the family it turned out to belong to

[lib/parse.ml]'s when required body <> [] and failed "when is (when test body …)". The guard is gone and an empty body is the Do [] that (do) already means. A (when) with no test at all is still refused, because there is nothing to branch on.

unless is a prelude macro now, not a special form, and carried the same restriction as (< (len args) 2). It is (< (len args) 1), and its unknown-name report narrowed with it: unless-takes-a-test rather than unless-takes-a-test-and-a-body, since the body is no longer part of the claim.

The author then added the third member of the family, and it turned out to be two different questions:

  • (defn foo [bar i32] ()) — a declared return type of () and no body — was already legal, and the refusal for the other case was already the right one: [Check] says "foo returns i32 but has no body" at the declaration. No change; both are pinned now, which they were not.
  • (fn []) was refused by the parser, by the same body <> [] guard when had. Dropped. An fn declares no return type, so "legal exactly when the return type is ()" has to be decided somewhere else, and the position it is written in is the only thing that knows: check_fn now refuses an empty body at a non-unit want — "an fn with no body answers (), and this one is in a position that wants i32". That refusal is new and it was needed: without it the empty body fell straight through check_fn's List.rev fbody match, the fn compiled, and the call read a return value nothing had written. So relaxing the parser here opened a hole that had to be closed in the checker, which is not true of when or of defn.

() as a unit value in expression position — considered and dropped

The author, 2026-09-20, closing the question DISCUSS.org left open beside (rl/with-drawing ()): making bare () a unit value in expression position was considered and is not wanted. Empty forms doing the right thing — the three above — covers the need that made it look attractive, and () stays unspoken-for in value position on purpose, against the possibility that the language grows lists later and wants the spelling. (Paraphrased from the author's note, not quoted.)

So () remains the type-position spelling of Unit and nothing else, and the guard below is written against that rather than around it.

(rl/with-drawing ()) — a body that was not written, spelled the second way

[vendor/raylib/modes.flan]'s guards caught zero arguments and not one argument that was itself (), so the latter was spliced into the expansion verbatim and the report came out of the middle of the expanded do saying () is not an expression — several forms from anything anyone wrote. All five with-* macros now treat a lone () where the body goes as no body, answering the same unknown-name they already answered for the missing one.

Only a lone (), and only in the body position. () as a camera or a render target is left to fail on its own: nothing the macro could say about it would be truer than what the compiler says.

The macros needed a predicate they did not have. form-items cannot tell () from a symbol — it answers the empty slice for both — so [lib/prelude.ml] grew form-empty-list?, which matches Form.List and asks its length.

(comment …), built in

A prelude defmacro answering (do) and reading none of its arguments, which is the whole feature: a macro's arguments are raw Form and are never checked as expressions, so what is inside never has to be a program. The pinned test puts an unknown function, a wrong arity, (+ 1 "two") and a field that does not exist inside one and compiles it.

The one rule it does obey is the reader's — balanced delimiters, legal tokens — because reading happens before any macro runs. #_ is the other spelling and they are not rivals: #_ is the reader's and discards the one form after it, so it works in argument position; this is a form of its own and takes any number, which is what a parked block wants.

inc/dec, ++/

The four the note spells out, in the prelude rather than per project. A word for the pure pair, C's punctuation for the mutating pair, so (inc i) in an argument and (++ i) as a statement cannot be confused the way C's i++ and i+1 can.

Generic for free, and verified rather than assumed: the pinned program runs inc over i8, i16, i32, i64, u8, u16, u32, u64, f32, f64 and a dyn, and prints the answers. Nothing in the four macros mentions a type, because + and - already work at all of them and a macro has no type to get in the way.

The accepted tradeoff, documented at the definition: (++ PLACE) expands to (set PLACE (+ PLACE 1)), so the place is read once and written once and is therefore evaluated twice. Free for a variable, a field or a deref. Not free for (at arr (next-index)): next-index runs twice and the read and the write land on different elements. Not fixable here — macros are non-hygienic by decision, and a macro cannot bind a temporary for a place without a reference type the language does not have. rl/with-drawing and rl/with-mode-2d already take the same trade on their arguments.

The note's four macros have no arity guard, and they needed one: (inc) would have indexed past the end of its own argument slice and failed inside the compiler rather than saying anything about the program. Each guards on (!= (len args) 1) — both too few and too many — and each is pinned.

Type-limit constants

[lib/prelude.ml] gained i8/i16/i32/i64 and u8/u16/u32/u64 max and min, and f32/f64 max, min-positive and epsilon. Kebab and the type's own name, following ns-per-second: i32-max, not INT_MAX. Each carries its type, so i32-max where a u8 is wanted is a type error rather than a silent 255.

The u*-min constants are all zero and are all there. A family with a hole in it is worse than four lines that say nothing surprising.

There is no f32-min, and the absence is the design. A float's least value is the negation of its greatest and needs no constant; what a caller reaching for "min" actually wants is the smallest positive one, which is a different number entirely. Naming either of them f32-min would put the collision at the worst possible place, so the name says which it is: f32-min-positive, the smallest normal value, as Rust's MIN_POSITIVE does.

u64-max is written in hex and has to be. The reader parses a decimal integer through Int64.of_string, and 18446744073709551615 does not fit one; 0xFFFFFFFFFFFFFFFF is read as the 64-bit pattern it names, which is what a u64 literal is here — [Check.in_range] accepts any pattern at 64 bits unsigned for exactly this reason. i64-min's decimal does fit, being i64's own least value, so it is written the ordinary way.

Every value is pinned against an independent derivation, not against itself. A wrong constant compiles — that is the whole hazard — so [test/programs/limits.flan] does not compare any constant to the way the prelude spells it. The integers are printed, and the expected text in test_acceptance is the decimal spelling written out from the definition of each type; an integer's decimal rendering is exact, so that comparison is the whole value. The floats cannot be pinned that way, because printing one is snprintf "%g" and 3.40282e+38 is equally true of f32-max and of a neighbourhood around it — so each is derived by exact power-of-two arithmetic and compared for equality. Every step of those derivations is exact in IEEE-754, and the two that are not powers of two have representable operands and a representable product:

constant derivation bit pattern
f32-epsilon 2^-23 0x34000000
f64-epsilon 2^-52 0x3CB0000000000000
f32-min-positive 2^-126 0x00800000
f64-min-positive 2^-1022 0x0010000000000000
f32-max (2 - 2^-23) * 2^127 0x7F7FFFFF
f64-max (2 - 2^-52) * 2^1023 0x7FEFFFFFFFFFFFFF

and each epsilon additionally against the property its name promises — adding it to 1.0 moves, adding half of it does not — and each max against there being nothing finite above it, since doubling one overflows to an infinity.

The program runs on both backends, and that is not ceremony: materialising a full-width u64 immediate and an f64 bit pattern is a different job in LLVM and in the hand-written x86 backend, and a lowering that truncated one would print a number this row catches and nothing else in the suite does. Both print identical text.

No infinity or NaN constant, and none is possible to write down. The reader has no literal for either. (/ 1.0 0.0) is the only route to an infinity today, and under the defconst-is-const rule decided the same day it is not one a defconst can take: the folding pass is integers only, so a float division is a computed initialiser and refused by name. So an f64-infinity defconst is not available without either a reader literal or a second folder, and neither is this lane's. Recorded, not added. The runtime test for one is in the prelude already and limits.flan reuses it: an infinity is the value that equals its own double and is not zero.

{.row .col} — and the collision the note said was not there

DISCUSS.org: "No obvious grammar collision — nothing currently matches a bare .field symbol on its own." That is false, and it was worth checking before relying on it. [dmap]'s pair arm takes any pattern in head position, and [destructure]'s first arm accepts any Sym as a name — a dotted one included. So before this change:

  • (let [{.x .y} p] ...) parsed, as "bind a local called .x to field y", and the program failed later with "unknown name x" pointing at the use. Verified against the compiler, not reasoned about.
  • an odd number of bare fields hit the [odd] arm and was refused, which is where test_flan's rejects_check "a field name with no pattern before it" came from.

So the dot in head position did have a meaning; it was just never a useful one. The new arm is checked before the pair arm and takes both readings away. The [odd] arm survives for the case it was actually written for — a plain name with nothing after it, {a} — and that test is now two: the old one inverted to an accepts, and a new one on {a}.

Where it works: let, and nowhere else, which is where {name .field} works today. Destructuring binds in let only. A match arm is not a second position the shorthand had to reach: a struct pattern has never worked in one, and the refusal there is the match grammar's own — "expected a pattern, found {a .x}" — not [no_pattern], which is what a defn parameter, an fn parameter and a dotimes counter get. Checked against the compiler rather than read off parse.ml's comment: {.x .y} and {a .x} in a match arm produce the same refusal as each other, which is the claim that matters — the shorthand inherited the existing rule rather than changing it.

An unknown field gets the named form's refusal unchanged, because it is the same field access underneath: "Point has no field z" with the declared_note listing the fields there are.

Pinned

  • test_flan.ml: (when c) parses to if + empty do; (when) still refused; (fn []) parses with an empty body; (fn) still refused; a defn returning () with no body accepted and one returning i32 refused; an fn with no body accepted at a (Fn [] ()) want and refused at a (Fn [] i32) one; the {.x .y} shorthand accepted plain, mixed with a pair, and nested; {.z} refused by field name; {.x} inverted from a refusal to an accepts; {a} refused; and both {.x .y} and {a .x} refused identically in a match arm, which is the "wherever the named form works" half of the claim.
  • test/programs/rl-with-empty.flan and rl-with-empty-arg.flan, through test_acceptance's refuses: the two guard shapes, a body starting at argument zero and a body starting after a camera, each given a bare () and each answering the name the zero-argument case already answered. Never built, which is how rl-with-reject.flan beside them works and is why these need no raylib on the machine.
  • test/programs/prelude-macros.flan, plain and -O0: comment with four different kinds of garbage in it; inc/dec over eleven types; ++/ over a local, a field, an element and a deref; empty when and unless bodies.
  • test/programs/limits.flan, plain, -O0 and x86: every constant, as above.
  • test/programs/destructure.flan gained shorthand and shorthand-mixed rows, so the shorthand is in the program that is the destructuring test.
  • test_acceptance.ml: the arity guard of every new macro, by the name it answers, plus unless's narrowed one.

Note for the concurrent lanes

The diagnostics lane owns check.ml's message strings. This lane added one new message at a new site — check_fn's empty-body refusal — and rewrote none. The parse.ml edits are structural: a dropped guard in when, a dropped guard in fn, a new arm at the top of dmap. Expect a rebase, not a conflict of intent.

The diagnostics pass, 2026-09-20

Worked from docs/DIAGNOSTICS-AUDIT.md, which is tracked as of this lane's first commit. Graded against the contract the audit sets out: show the code with the caret, say what was understood, say what conflicts, name the fix.

Reached

Worst-20 ranks 1, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, and the runtime half of 2. All four defvar follow-ups. Two from the author's dogfooding notes in DISCUSS.org: the foreign-spelling list, which answers int with i32 instead of a lecture about type variables, and the two-element defconst whose bracketed type read as an array literal.

Not reached, each with the reason

Rank 8: unhandled Boom has no location

Not a copy of the dyn-trap work, and the difference is the calling convention. flan_error takes five integer arguments — the type id, the condition, the channel, and the name as ptr+len — which is rdi through r8. A (loc, loclen) pair makes seven, past x86-64's six argument registers, so lib/x86.ml would need stack-argument passing at a call site whose own comment two hundred lines up says "the channel lands in r9 and the register file is exactly full". The dyn entry points took the pair without any of that because none of them was near the limit.

The rest of rank 8 — the condition's field values, and the handlers that were in scope — is separate work again and has no ABI question in it.

The audit's gap 4 names a dev-side half of this: flan_trap_hook hands control to a session that is in-process with the compiler and can read the source, so a real caret at runtime belongs in lib/dev.ml. That file is another lane's and the audit already wrote it up as a hand-off.

Rank 20: the fn-literal arity message

Re-read and judged already satisfying. The audit asks it to name the parameter list it was measured against; it prints the whole (Fn [T ...] R), which is that list. Left alone rather than churned.

trap_oom in flan_dyn.c

The other three trap printers took the location pair. This one is reached from gc_alloc, which has no site to be given: every allocation path in the file would have had to carry one for a sentence that is about the host refusing memory rather than about the program. trap_range has the pair and every caller passes NULL, so giving at, set-at and push a site later is a call-site change and not another round of signature churn.

The audit's structural gaps 3, 5 and 6

Printing the stable kind at the end of the first line, the "understood / conflicted" clause order as a writing rule, and non-cascading multiple errors through Loc.sink. Each needs a decision from the author rather than work, which is what the audit says about them too.

Two behaviour changes, not only wording

(defn idx [v i] dyn v) was refused and now compiles as two dyn parameters. The rule is the digits: this language sizes its machine types in the name, so a typo keeps them — f65, i33 — and a parameter called i or n has none. pair_params's own comment says that is what the feature is.

A defn whose name is a builtin's is still not refused. The builtin still wins every call and the definition is still unreachable; what changed is that the arity message says so and notes the definition. Refusing the shadowing is a language decision and was left to the author.

[Superseded the same day by the author's decision — see "Shadowing a builtin" below. The builtin no longer wins, the definition is no longer unreachable, and the arity note this paragraph describes has been removed along with the world it described.]

int and float as builtin aliases, 2026-09-20

The author, on the foreign-spelling list the diagnostics pass had just landed: "I think we can make an exception for int and float."

Exactly those two. int is i32 and float is f32; integer, long, double, uint, str and the rest keep the teaching refusal, and usize stays off the list entirely for the reason already recorded there — its width depends on the target.

Spelled as machine types, not as prelude aliases

Two implementations were on the table: two defalias~es in the prelude, or two entries where ~i32 and f32 already resolve. The second, and the thing that decides it is the cast.

Check.is_cast asks Types.ikind_of_name and Types.fkind_of_name whether a head names a primitive. It does not look in the alias table, and no user alias is a cast head today. A prelude (defalias int i32) would therefore have given (int x) no reading while (i32 x) had one — a spelling that works in type position and nowhere else, which is a second-class name and not what was asked for.

So: "i32" | "int" -> Some I32 and "f32" | "float" -> Some F32, plus both names on Types.primitive_names. That last is not decoration — type_named reads that list, and it is what decides whether (vec-new int) names an element type and whether a three-element (defvar x int) reads its third element as a type. Without it the identity would have been type-position-only again, one layer down.

Every other path is reached without learning the word: the resolver, the parameter-vector pairing, the $t refusal, the near-miss candidates, the let binding-vector annotation hint. Nothing in emit, x86, js or the runtime changed, or could have.

What the user sees: i32, always

ikind_name and fkind_name are the only way back from a type to a name and they have no int to give. So the erasure is total and in one direction: a program may write int everywhere, and every error message, every eldoc signature, every inspector line and every DWARF type name says i32.

(defn f [a int] int ...) reports as (Fn [i32] i32). A mismatch at a site spelled int says "expected i32". Both pinned.

This is the same erasure a user defalias already has and is the honest answer: the alias is a spelling, the type is the type.

Redefinition: true ones are no-ops, false ones are refused

The author's own programs contain (defalias int i32), written before there was a builtin. The rule is decided by the target, at registration:

  • (defalias int i32) and (defalias float f32) — accepted, and nothing is written to the alias table. The declaration is true, it is now redundant, and deleting the line is a cleanup rather than a fix.
  • Anything else — refused: "int is a builtin alias for i32 and cannot be redefined as i64 — delete this defalias, or give the type another name".

The alternative was the arity precedent, where the builtin won and a note surfaced at the error the shadowing caused — a precedent deleted later the same day, when shadowing a builtin became legal and the user's definition started winning instead (see "Shadowing a builtin" below); the reasoning below stands either way, because neither world has anywhere to put the note. It does not transfer: a (defalias int i64) has no later error site to hang a note on. resolve_name reaches ikind_of_name before the alias table, so the declaration would be read as i32 at every use and nothing would ever say so. Silence was the one unacceptable answer; refusing costs a rename in the program that meant it.

Nothing was added for (defvar int 5) or (defn f [int x] ...). Identity settles them: whatever those do with i32 written in, they now do with int, and both were already refusals.

Widening

No table entry, because there is nothing to widen — int is i32. Pinned as identity instead: (+ intvar i64var) is refused with "expected i64, found i32", the same message and the same spelling as (+ i32var i64var). Written that way so it survives whatever the widening lane lands: it asserts that the two spellings behave alike, not what either one does.

Pinned

Type position (parameter and return), cast head, (Vec int) and (Map int float), struct fields, (defalias Row (Vec int)), the three-element defvar zeroed static, int~/~i32 and float~/~f32 passing for each other across a call, the two erasure messages, the widening identity, both no-op redefinitions, three redefinition refusals, and integer, double and long still teaching. test/programs/int-float.flan runs the value half on both backends.

One existing row changed: the foreign-spelling pin in test_flan.ml used int, which resolves now, and was moved to long.

A macro's parameter list, and the one breaking spelling, 2026-09-20

DISCUSS.org's "defmacro should support real parameter lists" is built. (defmacro do-grid [[r rows c cols] & body] ...) — positional parameters, a [ ] pattern wherever the argument is a vector, nesting, and & for the tail. The list is read in lib/expand.ml (params_of, check_call), turned into bindings by lib/parse.ml (macro_body) and checked against a call by lib/macro.ml (checked_call) before anything is expanded.

THE BREAKING CHANGE: [args] was the whole call, and is now the first argument

This is the one decision in the lane that changes what existing text means, and it is here rather than in a commit message because it is the thing to disagree with if it is wrong.

A macro's single parameter was the whole argument list, so [args] meant "everything written at the call". Under a positional parameter list it cannot keep meaning that: one named parameter has to be the first argument, the way it is in every other language with parameter lists and the way Clojure has it. So the whole list is now spelled [& args].

The alternative was a legacy mode — one parameter with no & keeps the old meaning — and it was refused. It makes [a] and [a b] mean unrelated things, which is the kind of rule nobody can hold in their head, and it would have left the corpus written in a grammar the documentation no longer describes.

So every defmacro in the tree was migrated in the same commit. Seventeen files, mechanical, bodies untouched:

  • lib/prelude.ml — clamp, unless, into, and the dogfood batch's five (comment, inc, dec, ++, --), which landed on dev-loop after this lane branched and were migrated at the merge — eight in all
  • vendor/raylib/modes.flan — with-drawing, with-mode-2d, with-mode-3d, with-texture-mode, with-scissor-mode
  • vendor/edn/provide.flan — defedn; vendor/json/provide.flan — defjson
  • test/programs/ — macros.flan (7), macro-cycle.flan (2), macro-spin.flan, pkg-macro.flan, printers.flan, pkgs/mac (6), pkgs/macring (2), pkgs/macspin (1)
  • test/test_dev.ml, test_flan.ml, test_repl.ml, test_session.ml and emacs/test-flan.el — the defmacro fixtures written as strings

Nothing was rewritten to use the new grammar as part of the migration — with-mode-2d is still [& args] picking its camera out by hand, and its hand-written arity guard still says what it said. That was deliberate: the migration had to be a spelling change or it proves nothing. The new grammar is shown off in test/programs/macro-params.flan, which is its own program beside macros.flan.

The equivalence is asserted rather than assumed. pkg-macro.flan declares tenfold ([& args], (at args 0)) and tenfold-listed ([n]) with the same body, and test_session expands both and requires the same text.

Map destructuring in a macro's parameter list — deferred, refused by name

dmap (lib/parse.ml) is {:keys [x y]} over a struct: it reads field names off a declared type. A macro's argument is a Form, whose Map case is a flat run of alternating forms with no field names anywhere in it. So the pattern cannot be translated — it would have to be given a new meaning (match a keyword key in the literal map written at the call? bind by position?), and none of those is obviously the one somebody wants.

Vectors and & are the 95% case and are built. A map pattern in a macro's parameter list is refused by name where it is written:

map destructuring is not implemented in a macro's parameter list — a macro's argument is a Form, whose Map case is a flat run of alternating forms with no fields to name. Take the form and pick it apart in the body

Pinned in test_flan.ml. Whoever wants it should decide what it means first.

Flagged rather than patched by that lane, and fixed by the one after it. The diagnosis was right about the exception and one row off about where: the losing ask is the abort that ends the row, not the describe poll that opens it — every reproduction died on the line after flan: aborted at the break loop, four runs out of four.

The claim those rows now make. abort is the one verb that ends the process answering it, and in a merged flan dev that process is the daemon. flan_agent.c's listener writes its own ok and sets aborting; the break loop's next pass calls die_now, which is _exit(134), from the program thread — while the reply to the editor is still being composed on the serve thread, out of Dev.abort's ok. Nothing orders the two. So an abort that did exactly what was asked comes back either as :status "ok" or as the socket closing under the read, and both are the same outcome. Neither is the assertion: what says the abort worked is the waitpid wait underneath it, which every one of these rows already does. A new aborted helper in test_dev.ml answers None for the end that arrived as an exit, and the three sites that abort a stopped or trapped program go through it — the break row, the globals row and trap_park. The fourth abort in the file is the one a running program refuses; it ends nothing and was left alone, as was test_agent.ml's, which talks to the agent's own channel: the ok is on the wire before aborting is set, so the exit cannot overtake it, and that file's send reads to EOF and already swallows a Unix_error besides.

The describe poll that opens trap_park is guarded too, and with the other answer: these traps park because flan_trap_hook is installed, and with no hook rt_trap falls through to rt_die and the program takes the daemon with it — so a socket closing there is the trap having ended the program instead of stopping it, which is the failure that row already names. Closed on the abort is a pass; closed on the poll is a FAIL with a reason. Neither is a retry.

SIGPIPE is ignored in test_dev.ml for the watchdog's reason: with a daemon that exits by design, a Wire.send into the socket it left behind would kill the binary with no line saying why — the same silent shape, reached from the write side.

Measured after: 22 runs of test_dev.exe, all exit 0, no fatal exception; 5 runs of dune test --root . --force, all exit 0. --force because dune caches a test that passed, and a cached pass proves nothing about a race.

The two byte fills, 2026-09-20

DISCUSS.org's "a DEADBEEF-style sentinel-fill builtin", built. The author's answer to the single-byte-or-four-byte question was "why not both? we need some sort of memset -1 right? and dead-beef can loop, that's fine", so there are two builtins and they are siblings of zeroed, not a new shape.

Revised the same day: the pattern builtin was sentinel-filled and is now dead-beef, and it gained an optional operand so the pattern is the program's to choose. What did not change is filled, or the fill boundary, or the byte-order rule — the revision generalised the pattern, it did not reopen what may be filled.

The spellings

(filled BYTE) and (dead-beef) / (dead-beef PATTERN), all value forms driven by the type expected of them, exactly as (zeroed) is:

(set grid (filled 0xFF))
(set frame (dead-beef))
(set frame (dead-beef 0xBAADF00D))

A place-taking (filled place byte) was the other candidate and was not taken. zeroed already answers "the all-bytes-X value of whatever this is being stored into", set already takes the place, and a second spelling for an operation set expresses would have been a second thing to learn for nothing. The cost is real and is paid on purpose: a fill in a position that expects no type is refused ("filled needs to know the type it is filling"), which is zeroed's own refusal worn by both siblings.

dead-beef takes the pattern or leaves it out, and leaving it out is defined as writing the default: the checker's zero-argument arm builds the same Tast.Int 0xDEADBEEF the spelled-out call would have, so (dead-beef) and (dead-beef 0xDEADBEEF) are the same IR node by construction and no backend has a second path for the bare form. An acceptance row prints both and pins that they agree.

The pattern is an ordinary u32 expression, not a literal — the byte arm's rule at four times the width. A literal out of range meets in_range's located "does not fit in u32"; anything computed is guaranteed by its type instead, since a u32 cannot be out of u32 range. Refusing a computed one would have been a restriction with no mechanism behind it: neither backend needs the number early.

Reading note, for whoever reviews this. The revision asked for "a u32-range constant … decide literal-only vs any constant expression from what the byte-fill arm already accepts". The byte-fill arm accepts any u8 expression, runtime ones included, and the same instruction asked that both backends handle "eax loaded from a value, not an immediate" — which only exists if a computed pattern is legal. So the operand is any u32 expression. That is a strict superset of constants-only: every program the narrower reading allows behaves identically here. Tighten it to literals if that was the intent; nothing else depends on the breadth.

The fill boundary — what may be overwritten with raw bytes

Numbers, and structs and fixed arrays built out of numbers. Nothing else. Check.unfillable is the rule, in one recursive walk, and every refusal names the type it stopped at and why.

Zero is a value every type can have; 0xDE is not. That is the whole of why this rule exists and zeroed needs none:

  • dyn — a struct holding a dyn is rooted on the collector's root stack with a descriptor naming that word's byte offset. A filled one is a root pointing at nothing and the next collection follows it. This is the refusal the feature could not ship without.
  • Vec, Map, Allocator — an owning header: pointer, length, capacity, allocator. A filled one frees a wild address the first time it is touched.
  • string, slice — a pointer and a length that every bounds check believes.
  • Ptr — not walked by the collector, and a poisoned pointer is arguably the useful case. Kept out anyway so the rule is one sentence rather than "plain data, except one kind of address". This is the arm to relax first if the question is reopened.
  • bool — the one refusal that is about the backends rather than the runtime. A bool is a byte in memory and an i1 to LLVM, which reads the low bit, where x86 compares the whole byte against zero: 0xDE is false on one and true on the other. Byte-identical behaviour across the two backends is the property this feature is pinned on, so the divergence is refused rather than documented.
  • a data type — a tag that names a case, and no byte pattern names a real one. An (Option T) — the same, one bit of it: a filled tag says the value is there over a payload nobody wrote. An enum — its values are the members it declared, and no byte pattern is one of them.
  • a union — and this one is not about a tag, because env.unions is "the untagged unions". It is that a union's members overlay and unfillable walks a struct's fields rather than a union's members, so nothing has shown every member is plain data; a member that is not would be filled through the one that is. Relaxable by walking the members, if anyone wants it.
  • a function value — a code address, and a call through a filled one jumps into whatever the pattern happens to address.

Floats are in: every bit pattern is a float, NaNs included, and both backends move one as bytes.

A defconst of a fill is refused by the existing constant rule and not by anything of this feature's own — a fill is never a value the linker can write into the image. A defvar is fine and goes through the startup function on both backends, which programs/fill.flan pins.

The byte order, which is the specification

A pattern's ascending bytes are its big-endian bytes — exactly how the hex literal reads left to right. So (dead-beef) lays down DE AD BE EF and xxd reads "deadbeef"; (dead-beef 0xBAADF00D) lays down BA AD F0 0D. One rule, both arities.

On a little-endian machine the word a 4-byte store must therefore leave is the byte reversal of the pattern, which is all Emit.word_of_pattern is (bytes_of_pattern beside it is the ascending list). Those two are the one place the order is written, and Tast.dead_beef_default is the one place 0xDEADBEEF is written, so the default and the parameterised case cannot drift.

x86.ml reads word_of_pattern out of Emit rather than repeating it. It does not use bytes_of_pattern: its tail walks the bytes out of rax with shr, which is the same arithmetic the list encodes and is how the computed path has to do it anyway, so there was no second constant to share. emit.ml uses both — the list for a folded tail, the word for the loop.

A literal pattern is reversed at compile time and reaches the loop as an immediate — the default's generated code is exactly what it was before the pattern became an operand. A computed one is evaluated and reversed at run time, by llvm.bswap.i32 on one backend and bswap eax (0F C8, new) on the other.

Tail behaviour. A size that is not a multiple of four ends on a prefix of the ascending bytes: 1 byte over is DE, 2 is DE AD, 3 is DE AD BE. Equivalently, tail byte k is (word >> 8k) & 0xFF — which is what the computed path actually does, by shifting, since there is no constant to fold. programs/fill.flan has all four lengths (8, 9, 6, 7) and, crucially, runs a computed pattern over lengths 6 and 7: that is the case a constant-only implementation would pass by accident.

The backends

  • LLVM (emit.ml). The byte fill is one llvm.memset with the byte as an operand instead of a zero — the same call the existing bulk zero makes, and the reason the single-byte fill is the cheap one. The pattern fill cannot be a memset at all (the intrinsic takes one repeated i8, which is the snag DISCUSS.org named), so it is a counted loop over dwords in the header/body/exit shape emit_while writes, with the counter as an entry-block alloca that mem2reg promotes. Every store is align 1, because a [7 u8] array is a legal thing to fill. A computed pattern goes through llvm.bswap.i32 (newly declared) and the loop stores an SSA value rather than a constant; the tail then shifts and truncates.
  • x86 (x86.ml). rep stosb for the byte fill — zero_loc's three registers with the program's byte in al instead of a zero — and rep stosd (new, 0xf3 0xab) for the pattern, with the stored word in eax. A computed pattern is loaded and run through bswap (new, 0F C8); the tail walks the bytes out of rax with shr by an immediate (new, C1 /5), which is used rather than shift_cl precisely because rep stosd leaves rcx at zero. Either operand is evaluated before rdi is loaded, because evaluating one may call and a call clobbers rdi; rep stosd does not touch rax, which is what lets the tail keep reading the word out of it.
  • The one asymmetry: emit.ml needs a Tast.Set arm of its own to fill the place rather than a temporary, because its value path returns an SSA value. x86.ml needs none — a Set there already lowers its value into the place's location, so filling a place and filling a temporary are the same line.
  • js.ml refuses both by name. A struct is an object there, not a run of bytes, so there is nothing for 0xFF to mean.

What was run

dune test --root . green (exit 0, no FAIL lines). Three acceptance rows over test/programs/fill.flan — default, -O0 and --x86 — and the three outputs diffed against each other by hand before the rows were written: byte-identical, with a fourth build (--dev) added at the rename: four-way identical. The three rows were confirmed to actually run, by breaking one expectation on purpose and watching all three report. Seventeen checker rows in test_flan.ml: four accepting (both dead-beef arities and a computed pattern among them), and seventeen refusals covering the boundary — one per reason, since review found the tagged types were sharing a line that was false for two of them — both arities, both no-expected-type positions, the byte's range, the pattern's range and the defconst rule.

dune test exits 1 on this branch about half the time, with no FAIL line anywhere — the Flan.Wire.Closed flake an earlier lane wrote up further up this file. Green runs are real (2 of the last 4 exit 0); the rest are that race.

This lane makes it fire more often, and that is worth saying plainly rather than filing the whole thing under "known flake". Measured, because early runs looked like the lane had broken something:

what full dune test
base commit 1526b6f, none of the lane 0 failures in 5
this lane 5 failures in 5
this lane, my 3 acceptance rows off 1 failure in 3

Isolated, test_dev.exe alone (15 seconds, not the ten-minute suite) gives 4 in 6 here against 2 in 6 at the base — much closer, which is the shape you would expect if the lane is not touching the racy code but is changing the load around it. The three acceptance rows add three compile-and-run jobs to the pool that test_dev runs alongside, and a busier machine is slower to answer the poll that races.

So: not a new defect, and nothing in check.ml~/~emit.ml~/~x86.ml here is implicated — but the next lane to add acceptance rows will push the rate up again, and the fix the earlier writeup already named (catch Closed in trap_park's poll and read it as the program having ended) is now worth doing rather than noting.

One detail to add to that earlier writeup, which had only seen the flake on dev-trap-null-alloc: it is not row-specific. Five of my six isolated failures were that row and the sixth was dev-trap-free-all, so what is racy is trap_park itself and every row that calls it — which is exactly what the mechanism described there predicts. Per the sweep policy the @x86 and @sanitize sweeps were not run here.

Shadowing a builtin, 2026-09-20

The author's decision, in the author's words:

"allow shadowing but warn" — a user (defn get ...) colliding with a builtin is legal, the USER'S definition wins at call sites (real shadowing, Clojure's model: the def takes over, a warning says so), and the compiler warns once at the definition site.

Where builtin-wins actually lived

Not in a table and not in a precedence list. named_call is one match name with whose arms are the builtin names written out as string literals, and the three arms that look anything up — a local of Fn type, gsigs, then env.fns — are the last three in that match. So a builtin won because OCaml tried its arm first, and for no other reason. env.fns never outranked anything; it was simply never reached for a name spelled like a builtin. The old comment above arity said this outright ("the dispatch above reaches every builtin arm before it ever looks in [fns]") and is the only place it was written down.

The resolution change

One guard, first arm of named_call:

| _ when shadows_builtin ctx loc name -> ordinary_call ctx ~want loc name args

and the three trailing arms factored into ordinary_call so that both routes — falling past every builtin, and being sent straight there by the guard — resolve a name by exactly the same rules. Order is now total and reads the way a reader would guess: local of function type, then generic signature, then the function table, then the builtins, then the struct and the did-you-mean refusals.

shadows_builtin asks two questions, in this order. Is the name a builtin's: one lookup in builtin_set, false for every call to an ordinary function, and asking it first is also what keeps the arms that are not calls — an enum cast, a cast to a type variable, a machine-type cast — exactly where they were. Then, and only then, is there a definition that reaches this call: a local of function type, or a defn written in this same file.

builtin_set is a Hashtbl and is new. The guard is the first arm of the dispatch, so it runs at every named call, and the list builtin_names that already existed is walked linearly — about a third of check time on a program of twenty thousand calls, measured in review. The list stays for the did-you-mean, whose order is its order; the set answers the membership.

The warning, verbatim

shadow-builtin.flan:20:7: warning: get shadows the builtin get — every call in this program now reaches your definition — the builtin stays reachable as builtin/get
  20 | (defn get [p P] i32 (.x p))
     |       ~~~

The clause after the second dash arrived a day later with the escape hatch itself; this entry shipped without it, because there was nothing to name.

Rendered by Loc.entry ~mark:'' label:"warning: ", which is the --warn-memory precedent, so flycheck parses it exactly as it parses an error. Nothing raises and the exit status does not move. Unlike --warn-memory it is behind no flag: there is nothing to tune, and the line is one line and rare.

It is printed from Check.build_program rather than from bin/main.ml beside print_memory_warnings, because every route into the compiler passes through that function — build, check, run, and the dev daemon's reload, which is where a defn is most likely to be written. The list itself is Check.shadowed_builtins, a pure function over the declarations, which is what the tests ask.

Scope, settled from the code

Package-wide or program-wide: neither, and the mechanism already decided it. Load qualifies every name an imported package declares to alias/name, including its own uses of them, so a package's get is rl/get and cannot collide with a builtin at all. What is left is the other direction: a program that defines get and imports a package whose body calls the builtin get. That call must keep meaning the builtin, and it does: the shadow reaches exactly the file the definition was written in, which is the same visibility a defn has everywhere else. The prelude falls out of the same rule rather than needing one of its own — it is a file, and not the one the program is in.

The file and not the enclosing function's name, which is what this first shipped with and was wrong. A package's functions are qualified at the import, so "does the owner's name carry a slash" answers correctly wherever a call sits inside a function — and wrongly in the one place a call does not. Review demonstrated it: a program defining (defn len ...) reached inside an imported package's (defvar sz i32 (len "abcd")), which is checked with no owner at all, and made it 999. A global initialiser has no enclosing name; it does have a file.

programs/shadow-builtin.flan is every half in one program: 7 is the program's own one-argument (get p), 4 is the builtin get called inside the package it imports, 99 is a shadowed +, 999 is the program's own len, and the last 4 is that same len inside the package's global initialiser, where the builtin still means the builtin.

Prelude macros. No rule was needed: the namespace is already one. (defn comment [x i32] i32 ...) against the prelude's (defmacro comment ...) is refused today as "comment is defined twice", with a note at the prelude's definition, and the same for inc and dec. Shadowing a builtin is a different question precisely because a builtin is not a declaration — it is an arm in the compiler, with nothing for a redefinition check to point at. Macros expand before checking and key on the head name unconditionally, so if the redefinition check were ever relaxed the macro would win and the defn would be unreachable; that is not a state this compiler can reach, and nothing was written to handle it.

What the file rule costs. A bare REPL expression — C-x C-e on a form, evaluated with origin <eval> and no file behind it — is not the file the defn was written in, so it reaches the builtin. C-c C-c sends the buffer's own path and is unaffected, which is the case the dev loop is actually made of. It is the conservative direction: a REPL line meaning the builtin is a surprise, a REPL line silently meaning a definition somewhere else is a worse one. If it ever bites, the fix is for the session to evaluate with the buffer's path as origin, which it already knows.

A macro named after a builtin warns too, and that is right. (defmacro get [args] ...) is an Ast.Defn like any other by the time the declaration list is collected — a macro is a function the compiler runs — so shadowed_builtins names it and the warning reads the same. The macro also wins, and by a different mechanism: expansion runs before checking and keys on the head name, so the call never becomes a call at all. The one wrinkle is that a file carrying macros is checked twice, the macro module first, so its warning is printed twice. Disclosed rather than suppressed: dropping a duplicate means keeping state across the two checks, and the second line is the same line.

The dead end: a shadowed builtin has no remaining spelling. Nothing in this language qualifies a name — there is no core/get, no (builtin get) — so a file that defines get has given up the builtin get for the whole file, and a definition that wants to wrap the builtin cannot. (defn len [s string] i32 (+ 1 (len s))) is not a wrapper, it is unbounded recursion: the inner call reaches the definition being written, and the program stack-overflows at run time with no diagnostic from the compiler, which has nothing to object to. The warning says the name is taken over; it does not say this. An escape hatch is a language decision and is with the author.

Closed the next day. The author's answer was the qualified spelling — see "builtin/, the reserved qualifier, 2026-09-20" below. (defn len [s string] i32 (builtin/+ 1 (builtin/len s))) is the wrapper this paragraph said could not be written, and it runs: programs/builtin-qualified.flan prints 5 for it beside the builtin's own 4. The warning's sentence now carries the escape, so the reader is told what is left at the moment they are told the name was taken over.

Pins

  • test_flan.ml: the warning's kind, line and column; its message, matched whole and not by needle; that it carries no notes; that the source which used to be refused now checks; and that a program shadowing nothing warns not at all.
  • test_flan.ml, from review: a shadowed operator warns with the same sentence and lowers to a Call to the definition rather than the Add prim; and a call read with another file's name, against the same declaration list, reaches the builtin and is refused at the builtin's arity — the global-initialiser case at its smallest.
  • test_acceptance.ml: programs/shadow-builtin.flan outputs 7\n4\n99\n999\n4\n, and the @x86 sweep compares both backends over the same file.
  • Removed: the check/builtin-arity kind, its message ("this is the builtin get, which a defn of the same name does not replace"), its note ("is also defined here, and this call is not reaching it — rename it to call it"), and the three checks that pinned them. The situation cannot arise: the call reaches the user's defn, whose arity is whatever it declared.
  • Changed: the builtin-arm/~Check.builtins~ cross-check reads named_call's source down to ~ | _ ->~ rather than ~ | _~, because the new first arm is guarded and stopping at it read the whole region as empty.

One thing the new package cost

A package under test/programs/pkgs/ needs a glob_files line of its own in four places in test/dune — the test stanza and the @valgrind, @x86 and @js sweeps — because dune's glob does not descend and the sweeps walk programs/*.flan whole. Without it the corpus row fails with "no package at …" and prints no FAIL line, only "1 failure(s)" at the end of the log: worth knowing, because a grep for FAIL says green over it.

What was run

dune test --root . in the lane's worktree, forced: exit 0. Rebased onto dev-loop before the review follow-ups, so the arity signature this lane cuts down is the one the byte-fill lane had just given a ctx argument, and the int~/~float section's paragraph about "the arity precedent, where the builtin wins" is revised in place — that precedent is what this lane deleted. The heavy sweeps (@x86, @sanitize, @valgrind) were left to the batch.

Lazy instance migration for a redefined defclass, 2026-09-20

CLHS 4.3.6 — the update-instance-for-redefined-class protocol — adapted to the dyn side's classes, minus the user hook. Redefining a defclass in the dev session used to be silent: a class is compile-time sugar for a constructor defn, so the edit replaced a function body, the instances already in the program kept their old keys for ever, and nothing anywhere said so. Now the instances follow the class.

The research is docs/SBCL-REDEFINITION-NOTES.md, candidate C. Its central finding is why this was cheap and why the same thing is not available for a typed defstruct: every SBCL mechanism of this kind rests on an instance carrying a pointer to its shape, and a dyn instance has a header where a flat struct does not.

What it does

(defclass point [x y])
;; ... a program runs, builds instances, holds them in globals ...
(defclass point [x z])          ; C-c C-c, with the file's callers if any
;; every live instance, at its next touch:
;;   :x keeps the value it had         (matched by name)
;;   :z appears as nil                 (gained)
;;   :y is gone                        (dropped)
;;   the object is the same object     (identity preserved)
;;   (class-of p) is still :point      (so every method still reaches it)

Nothing is enumerated and no heap is walked, which is the part the old deferral thought was missing. The redefinition is O(1) — one registry entry updated — and the work is paid per instance, once, by whoever touches it.

The three pieces

A registry, in the runtime

runtime/flan_dyn.c, under "Classes": one entry per class name, holding the current slot list and a generation counter. flan_dyn_class_def(name, slots, n) registers or re-registers one; slots is the names packed into a single string with newlines between.

Nothing in it is a collector object, and that is the whole GC argument. A class's name and its slots are interned kw_entry pointers — immortal, not on the collected heap, never traced — which is the same argument the klass header field already makes. The table itself is ~malloc~ed, append-only and never freed. So no root is pushed for the registry, the marker has nothing to reach in it, and a collection triggered from inside a migration cannot see a half-built slot list. A registry of dyn vectors would have needed all three of those worried about.

A generation, in the instance's header

A uint32_t in flan_obj, in the padding between mark and len. sizeof(flan_obj) is 48 with it and was 48 without it — the union is exactly 24 bytes (items, cap, klass), so there is no spare word inside the arm and a field placed after the union would have cost eight bytes on every dyn value in the heap for a word only class instances read. The obvious guess before reading the struct is that view.is_vec leaves four spare bytes at offset 44; it does not. That word is the view arm's and is aliased with klass — the arms overlap, so nothing inside the union is free. The free bytes are the ones alignment already wastes, in front of it.

The number is asserted rather than commented: flan_dyn_obj_size() is a new entry point and dyn_ops.c's classes mode checks it against 48, so a later field that pushes it out fails a test instead of costing that silently.

Zero means "built before any definition was registered", which is every instance of every program that was built and never reloaded. The first registration of a name lands on 1, so those instances migrate exactly once, the first time the class is redefined under them — which is what makes a program that predates this correct rather than merely unbroken.

A registration thunk, per reload

lib/session.ml's change emits one nullary function per evaluation that declared any class, calling flan_dyn_class_def once per class, and hands it to Emit.redefinition~/~X86.redefinition as ?call — the mechanism C-x C-e already uses, where the agent finds flan_reload_call by dlsym and runs it after the module's bodies are published and on the game thread. Both backends, unchanged: the thunk is an ordinary Tast function and the backends lower Rt calls generically.

It has to be a thunk and not something in the constructor. The case this exists for is a class redefined and not constructed — old instances touched after the edit — and a registration that only ran at construction would never fire for it. That is the same reasoning that rules out registering from main: reload modules re-execute their definitions, not their program.

Every class in the form is registered, not only the ones whose slots changed, because a class the registry has never seen has to arrive somehow. The bump is what is conditional: re-registering an identical list changes nothing, so a C-c C-k costs one comparison per class and migrates nothing. Without that rule every save would migrate every instance in the program.

Where a migration happens

want_map (so get, put and has-key?), flan_dyn_len's map arm, and dyn_equal's. CLHS asks for "no later than the next time a slot of that instance is read or written"; those are the three places that read or write the slot set.

Neither printer is one of them, and that has a consequence somebody will meet. render — which print goes through, and which the editor renders every dyn value with — and say_render — the 96-byte sentence a trap prints — both walk the entries raw and neither syncs. say_render runs inside trap reporting, where the heap is whatever the trap left, and a printer that frees an object's entry block and installs another is not something to have on that path; render is its sibling and is reached from it for nested values, so splitting them would put the mutation one recursion below a trap anyway.

So: a stale instance shows its old slots to the editor until something touches it. A watch expression, the value C-x C-e answers and the inspector's render of a dyn all arrive through render, so in the moment after a defclass is redefined the inspector can show a slot the class no longer has and omit one it has gained — while (get p :z) typed at the same instant answers the new definition, migrates the instance, and makes the inspector agree from then on. CLHS's "implementation-dependent time" permits it; it is the price of the printer staying a printer; and it is disclosed here rather than discovered.

The migration rebuilds the entry block rather than compacting it in place, and writes the slots in the class's order. One malloc per instance per redefinition, and the property bought is that a migrated instance is indistinguishable from a freshly constructed one — dyn_equal compares maps by lookup and would not have cared, but len and render work in insertion order and would have.

Equality across generations: migrate first

Two instances of one class built either side of a redefinition, holding equal values for the slots the class still has, are equal. dyn_equal migrates both operands before comparing the tag or the length. The decision recorded: equality is over the class as it is now, not over the shapes the two values happened to be born with. The alternative — comparing key sets literally — would answer "not equal" about a difference the class no longer has, and would make the answer depend on which of the two had been touched since.

The registry is advisory, and this is the honest cost

A class instance is an open map. put takes any key — FIX.org already defers refusing (get p :z) — so a program can write a key the class never declared, and the next migration drops it, because the migration's rule is that an instance's keys are the class's slots.

That is data loss, and there is no enforcement behind it to make the loss impossible. Enforcing would mean refusing an unknown key at put, which is the static slot discipline the dyn side deliberately does not have, and the research names this exact risk: "if put of an arbitrary key stays legal, the registry describes an intention rather than a constraint". It describes an intention. test_dev.ml pins the loss as behaviour rather than leaving it to be discovered.

What the session had to give up to allow it, and what it kept

A slot added or removed is a constructor signature change, which session.ml's compatible refuses by default — a call site compiled to pass two dyn words into a three-parameter body leaves the third holding a register, and a dyn word that is not a value is a wild pointer rather than a wrong answer. Item 6's own line above — the constructor "is an ordinary defn — so its arity refusal, its cell in a dev build and its behaviour under redefinition are the ones every function already has" — is true and was read one step too far: what every function already has is the signature refusal, so a class could not change its slots at all. That was the first thing this lane had to fix, before any of the runtime work could be reached.

The refusal is now lifted for a defclass constructor and nothing else, and only when no compiled caller is left behind. In practice the checker gets there first: the whole declaration list is re-checked against the new constructor before compatible is consulted, so a declaration still calling it with the old count is refused at the call site with a line number — which is the sentence a reader sees, and is what test_session.ml pins. What change adds is the reason held locally rather than inherited: a caller that type-checks under the new arity is one whose source changed, so it is in this form and is republished with the class. The walk over t.program's bodies asserts that instead of assuming it, and if it ever fires the answer is a refusal naming the callers rather than a wild pointer.

Not touched: typed defstruct layout changes and typed global type changes keep their refusals. SBCL-REDEFINITION-NOTES.md §5 is why — a flat unboxed struct has no header to stamp and cannot change size in place, so none of this is available there at any price.

Deferred, with the reason

  • update-instance-for-redefined-class itself, the user hook. CLOS hands the discarded slots' values to a method so a coordinate change can be written by hand; the obvious Flan spelling is a generic, (defmethod update-for-redefined point [p added discarded] ...), riding the dispatch that already exists. Left out of v1 because the automatic half — name matching — is the half that makes redefinition usable, and the hook is what makes it expressive. Nothing about the design blocks it: the migration already computes both lists.
  • Initargs validation. CLOS's default method signals on an initarg the class does not declare. There are no initargs here; construction is positional.
  • Refusing an unknown put, which is what would turn the registry from advisory into enforcing. Same gate as the deferred (get p :z) check.
  • Rolling a failed migration back. SBCL wraps the user hook in nlx-protect so a signalling method leaves the instance on its old wrapper. With no user hook the migration cannot signal, so there is nothing to roll back yet; it becomes a real question the day the hook lands.
  • The whole-program build registers nothing. A program that is built and never reloaded has no registry at all, its instances carry generation zero, and everything behaves exactly as it did before this existed. Registering at startup would need an initialiser in both backends' executable paths and buys only introspection — there is no stale instance in a program whose classes never changed.

Pinned

  • test/dyn_ops.c's classes mode, run by test_dyn.ml: the object size, an unregistered class behaving as before, a slot gained, a slot lost, both at once, three definitions an instance slept through, a re-registration of the same list migrating nothing, two generations compared, a plain map untouched by any of it, and two thousand instances migrated while the collector runs. Driven from C because the event has no Flan spelling: a class definition changes between two modules, so no single program can see one change.
  • test_sanitize.ml's dyn_sweep runs that mode under ASan and UBSan. It is the one mode that frees an object's entry block while the object stays live and reachable, which is the shape a wrong marker would show as a use-after-free and as nothing at all in the checked build.
  • test_dev.ml, "a class redefined under its own instances": a real daemon over test/programs/dev-classes.flan, instances pushed into a dyn global by C-x C-e thunks, then five C-c C-c evaluations of the class — four of which change the slot list — with the program's own heap answering between them: gained slot nil, kept slot kept, count right, a generic still dispatching after the migration, an untouched instance migrating on its own first touch, a lost slot gone, the third generation, the tag surviving, the one unchanged re-evaluation migrating nothing, and a raw-put key dropped by the next real redefinition.
  • And the same protocol once more against a flan dev --llvm daemon. The block above runs on x86, which is what flan dev takes unasked; the subset under LLVM is the part that is backend-specific — whether the registration thunk reaches the runtime at all — and everything past that point is flan_dyn.c's, which does not know who called it. Written because x86.ml's header had claimed for some time that it did not emit flan_reload_call, which is exactly the kind of sentence not to trust twice.
  • test_session.ml: a slot added and a slot removed both accepted, the module carrying a call to flan_dyn_class_def, a definition of flan_reload_call and the packed slot-list constant, an unchanged class registering anyway with its own list, the refusal when a compiled caller is in the way, and the same edit accepted when the caller comes with it.

Found on the way

lib/x86.ml's redefinition header said "the transient flan_reload_call thunk is not built here, and is refused by name". It has been built there for some time — the code is at the bottom of the same function — and the comment had simply not moved with it. Corrected rather than worked around; this lane's thunk goes through that path on every C-c C-c of a class, which is the default backend for flan dev.

A second one, found by the review rather than by the lane: two of the test_session.ml pins above asserted the string flan_dyn_class_def against the module's IR text, and emit.ml writes a declare for every runtime entry point into every module it emits — so both passed against a module that registered nothing. They assert call void @flan_dyn_class_def and the packed slot-list constant now. Confirmed by mutation: with the thunk suppressed the old needles pass and the new ones fail. Worth carrying as a habit rather than as a fix — a needle that names a runtime symbol is matching the declare block unless it says call.

What was run

dune test --root . green (exit 0, no FAIL lines) before and after the rebase onto dev-loop, and test_dev.exe run directly afterwards because its label can be swallowed by a cached run. dune build --root . @sanitize clean on the committed source, which is where the two-thousand-instance migration under collection actually gets looked at.

The rebase is worth a line of its own. Three conflicts were additive — FIX.org, want_map (the diagnostics lane gave trap2 a location pair, this one put a class_sync beside it, both wanted), and test_dev.ml's agent-socket block beside this one's. The fourth was not a conflict at all and is the one to remember: flan_dyn_class_def's argument check was written against the four-argument trap1 and merged clean into a tree where trap1 takes a location first, so the class name would have been read as a length. dune build does not compile flan_dyn.c — it is a string the compiler carries and hands to clang at flan run — so a green build is not evidence about that file at all. dune test is, and so is running any program.

Implicit widening, 2026-09-20 — "go with C"

Answers DISCUSS.org's implicit numeric conversions with a warning flag, instead of hard errors. The ask there was a warn-instead-of-refuse mode; the answer is narrower and needs no mode and no flag.

The decision. Implicit numeric widening is legal — every conversion that cannot change the number. Narrowing stays a hard error everywhere, with no flag that turns it into a warning. Odin's position roughly; Rust's no-conversions-at-all position is rejected, and so is C's, which is what DISCUSS.org's -Wconversion middle ground would have reproduced.

So there is no second type-checking mode, which was the objection in the note: one predicate says which conversions exist, one helper inserts the Cast for them, and everything else in the checker is unchanged.

The lattice

Types.widens_to ~from ~into (lib/types.ml). One rule decides every row: a conversion is admitted exactly when no value of the source can come out the other side as a different number.

from widens implicitly into
i8 i16 i32 i64 f32 f64
i16 i32 i64 f32 f64
i32 i64 f64
i64 — (nothing)
u8 u16 u32 u64 i16 i32 i64 f32 f64
u16 u32 u64 i32 i64 f32 f64
u32 u64 i64 f64
u64 — (nothing)
f32 f64
f64 — (nothing)

Read off the rule, one clause at a time:

  • Same signedness, strictly wider — the uncontroversial half.
  • Unsigned into strictly wider signedu8~→~i16, u32~→~i64. Every value of the source is a value of the target, so it is in.
  • Signed into unsigned — never, at any width: the negatives have nowhere to go.
  • Equal width across signedness (i32~→~u32, u32~→~i32) — never, for the same reason. Half the range would have to move.
  • Integer into float, exact only. An f64 significand is 53 bits, so everything 32 bits and under reaches it and i64~/~u64 do not — 2^53+1 is not an f64. An f32 significand is 24 bits, so only the 8- and 16-bit integers reach it. Odin allows any integer into any float; this is the tighter rule deliberately. A program that wants i64~→~f64 writes (f64 x). Loosening this later adds programs; tightening it later would break them, which is why the loose version is not the one that landed.
  • dyn is not in the lattice. Crossing into and out of a box is box~/~unbox and is untouched — in particular a dyn still only unboxes to i64~/~f64~/~bool, and a narrower want there is still the refusal lib/check.ml's unbox has always given.
  • Containers are invariant. A [i32] is not a [i64], a (Vec i32) is not a (Vec i64), an [8 u8] is not an [8 u16]. Widening rewrites a value with a Cast; there is no value to rewrite in a slice that does not own its bytes, and rewriting a Vec would mean allocating a second one.
  • bool and an Enum are not numbers and are not on the list. A keyword still resolves against an enum and a bare integer still does not fit one.

Not expressed as a loosening of equal or fits, deliberately. widens_to is a separate predicate precisely so that admitting a conversion is always paired with inserting the Cast that performs it. Had fits been loosened, every site that accepts a value without rewriting it would hand the backends a node whose type lies about the bits it holds.

Where it applies

Check.expect (lib/check.ml) is the single place a wanted type meets a produced one, so one arm there covers the whole surface: argument passing, return position, let and defvar with an annotation, struct field initialisers, Vec pushes, set!, every C import's parameters. Nothing else had to learn about widening except the binary operators, which have no "wanted type" to meet.

The join rule for binary operators

Both operands of a binary operator have one type, and the old comment said "there is no implicit widening, so one side has to decide it". The decides-rule generalises rather than disappearing:

  1. An expectation still wins, and it reaches the operands. When the site wants a type — (defn f [] i64 (+ a b)) — that want is threaded into both operands as before, and now widens them. The addition happens at i64, not at i32 followed by a widened result. That is the better of the two and it is only reachable by programs that did not compile before.
  2. Literals decide exactly as they did, and this one had to be defended. y_decides and needs_want are untouched: a literal takes its width from the other operand, a float literal outranks an integer one. (+ x 1) over a u64 x still builds a u64 one, which is what keeps (let [h fnv-offset]) with a u64 defconst meaning exactly what it meant. Saying so was not enough. The join is implemented as a trial — ask the second operand for the first's type, and reconsider if it refuses — and the first version of it reconsidered a literal too, which silently moved (+ u8-thing 300) from "300 does not fit in u8" to i32 arithmetic answering 555, asymmetric in the operand order, and (+ i32-x 1.5) to an f64 add. That is a different language from the one decided on. A literal that does not fit is the program's mistake and not a pair of types that failed to meet — the literal had no type of its own to bring — so the three refusals that say so (in_range, and the integer and float literal arms of check) now carry the kind check/literal-at-want, and the trial re-raises on sight of it rather than looking again. Pinned four ways: the literal as the operand, the literal buried inside one, the float-literal spelling, and a literal that does fit still taking the operand's type.
  3. Otherwise the wider side decidesTypes.join: whichever operand the other widens into, with the loser wrapped in a Cast to it. (+ i32-var i64-var) is i64 and is newly legal. (min i8-var i16-var) is i16.
  4. Equal-width cross-sign still refuses. (+ i32-var u32-var) has no join — neither widens into the other — and the message names the cast to write.

join is not a real lattice and is not meant to be: (i32, u32) has no answer, and inventing i64 for it would pick a type neither operand was written at.

Folds are still folds. (+ a b c) is ((a + b) + c), so the join is pairwise and left-to-right: the first pair settles a type and the third operand is checked against it. (+ i8 i8 i64) therefore still refuses, where (+ i64 i8 i8) passes. Left to stand rather than joined across the whole argument list, because changing that would change what (- a b c) means, not only what it admits.

Shifts are carved out. << and >> do not take the plain join: the value decides, and the count widens to the value's type. Under the general rule (<< u8-var i32-count) would widen the value to i32 and the result type and the wrap width would silently follow the count's declared type — and the emitter's poison mask is keyed to the value's width. A count wider than the value is refused and says so.

Const folding is unchanged, and was never the thing it looked like

The defconst integer folder (const_int, lib/check.ml) runs on the AST, before anything has a type, and carries one int64 per constant with no width attached. So it already folded across widths and still does — (defconst w i32 4) times (defconst h i64 5) has always been a constant 20, usable as an array length — and widening neither added a fold nor removed one. Measured, not assumed.

The one thing that did change is at the edges rather than in the folder: it answers nothing for a Call whose operator is not one of the five arithmetic names, and a written cast is such a call. So (* w (i64 h)) was not a constant and (* w h) is — which means dropping a cast that widening made unnecessary can turn a run-time computation into an array length. That is widening adding a program, the same as everywhere else, and needed no change here.

No overload resolution to disturb

Worth saying plainly, because widening is exactly the change that breaks overloading in a language that has it: this one does not. Every builtin is dispatched by name in named_call — there is no set of candidates to pick between, so widening cannot change which one fires and cannot make a call ambiguous. min~/~max and the arithmetic builtins looked like they keyed on types, and what they actually do is check a predicate (ordered?, numeric?) against the type the operands already agreed on. Widening changes what they agree on and nothing about the dispatch.

Sites changed, and sites kept

Changed, three of them and no more:

  • lib/types.ml — widens_to and join, new. equal and fits untouched.
  • Check.expect — one arm, which is the entire annotation surface.
  • Check.binary — the join, and ~join:false for the shifts.

Kept, with the message saying narrowing rather than "no conversions":

  • Check.unbox's per-width refusal at the dyn boundary. A dyn carries one integer width and one float width, so there is no narrower source to widen from and nothing on the lattice reaches it; what it refuses is a truncation at the one boundary where the value's type was already uncertain, and that is as true as it was.
  • Every numeric refusal that survives expect now carries numeric_note, which tells the two surviving cases apart: a narrowing names the cast and points out that the other direction is free, and an equal-width cross-signed pair is told that neither direction exists.

Comments rewritten rather than left to rot, each now stating the new invariant rather than the old one: lib/types.ml's header, equal's note (why widening is deliberately not a loosening of it), Check.unbox, Check.binary, the bitwise and shift arms, the embed two-spellings argument (which turns out never to have rested on widening at all — it rests on containers not converting), lib/prelude.ml's print note and both sum- notes, docs/BUILT.md's gravity and #load paragraphs, test/programs/embed.flan, and the +, bit-and, <<, >> and min lines of the builtins table. Left alone: docs/SPIKE-.md and docs/handoffs/, which are dated records of what was true when they were written.

What was run

  • dune test --root . --force — exit 0, 0 FAIL lines, on the lane and in a trial-merged tree. Through most of this lane it exited 1 instead, from test_dev.ml's trap_park rows racing and dying with Fatal error: exception Flan.Wire.Closed at dev-trap-null-alloc — measured on an untouched worktree at dev-loop's tip with nothing of this lane in it, and written up above under "Found while running it". Another lane has since fixed it (trap_park stops dying on the abort race), so the green run is a real green run rather than a lucky one. One other test_dev.ml row failed twice across seven runs of identical code — "the merged program never bound …agent.sock", a daemon that did not come up in time — and was green on every run either side, on the lane and in the merged tree. The second failure named its own cause: the corpus sweep was compiling in another worktree on the same machine, and the row gives the daemon a fixed window to bind in. Run on an idle machine it is green. Recorded rather than chased: it is a socket bind in the agent fixture, this lane touches neither the agent nor the dyn side, and it looks like the same family as the trap_park race that was just fixed, one row further along — a timeout that is generous when nothing else is running and is not otherwise.
  • test/programs/widening.flan, new, with three acceptance rows — default, -O0 and --x86 — and its output diffed by hand across the two backends before the rows were written. Byte-identical.
  • The lattice's edges pinned in test_flan.ml: what widens, what does not, the two calls that could have gone the other way (int-into-float exact-only, and equal-width cross-signedness), container invariance, the join in both operand orders, the literal rule still standing, and the shift carve-out in both directions.
  • Verified in a trial-merged tree, not only on the lane. dev-loop moved eight times while this was open, and the acceptance rows, the full suite and the sweep were re-run against the last of them. The branch caught up by rebase until the notes file made that expensive — every commit of this lane touches FIX.org and so conflicted with every landing that also did — and finishes with an ordinary merge of dev-loop into the lane instead, resolved once. The merge back into dev-loop is clean, and was built, run and tested as a merged tree rather than only on the branch.
  • The corpus sweep, base against lane. Headless programs (test/programs/) were compiled, ~check~ed and run, and the diff of the whole lot is a single pure addition: widening.flan's own rows. Not one existing program's diagnostics, output or exit status moved. The thirteen test programs that import vendor:raylib were not run either, for the same reason, and got the same treatment as examples/ below: check's diagnostics are identical on both sides, LLVM emit is byte-identical, and the x86 difference is the prelude-line strings and nothing else. examples/ were not run. They link raylib and every one of them opens a real window on the author's desktop, so the comparison there is check's exit status and diagnostics plus a byte-diff of emit and emit --x86. LLVM output is byte-identical for all of them — after the same prelude-line normalisation the x86 comparison needs, which the LLVM diff gets for free because it spells those strings out as text where x86 emits them as .byte data. The x86 output differs in 28 of them and every differing byte is inside a <prelude>:line:col string — this lane's comment rewrites moved prelude source lines by three, and the x86 backend spells those strings out as .byte data. Normalising the prelude line number makes both backends byte-identical everywhere.
  • A global-initialiser check by hand, both backends: a widened defvar initialiser, a widened struct field in a struct literal, a widened array element, and a widened set. The concern was that a Cast in an initialiser would stop being an LLVM constant; it does not, and the two backends print the same six lines. A defconst of a float from an integer constant is refused, with the existing "must be a compile-time constant" sentence — the folder is integers-only and says so.

What this lane did not do

  • dyn is untouched in both directions.
  • No Vec, slice or array element type converts, and nothing was added that could make one.
  • The @x86 and @sanitize sweeps were not run; per the sweep policy they belong to the batch after several lanes land. The individual --x86 builds the policy does require were run, and are the acceptance row and the sweep above.

Review round two: what the first version got wrong

Three findings, all in the mechanism rather than in the lattice, and all from the same root — the join is implemented as a trial (ask the second operand for the first operand's type; reconsider only if that refuses), and a trial that catches an exception is not free the way a trial that returns an option is.

1. An abandoned trial left its bindings behind. scoped restores ctx.scope on the way out, and an exception does not take that way out — so every binding the abandoned pass made survived into the enclosing scope. Two symptoms, and the second is the serious one:

  • a name that should be unknown resolved anyway, and
  • the abandoned binding shadowed a live one. (let [t i32-x] (println (+ i32-x (let [t i64-y] t))) (println t)) printed the sum and then 0 — the outer t read through the dead inner binding's slot, which nothing ever stored into. An uninitialised stack read, in a program the compiler accepted, on both backends.

Fixed with trial, which snapshots the context and puts it back when the trial refuses. scoped itself is untouched — it is shared by every scope-opening form in the file and this is not its problem to solve. trial also narrows the catch to Loc.Error: a timeout or a stack overflow is not a refusal to reconsider, and continuing past one would turn a resource failure into a wrong answer.

The first version of that fix restored six chosen fields, and the choice was wrong. Review round three found three more, and the worst of them inverts the symptom: where a leaked binding produces a false accept, a leaked window produces a false refusal.

  • in_frames. check_frames sets it, threads the expectation into the body's last form, and clears it on the way out. A trial abandoned inside that window leaves the flag stuck, so

    (println (+ i32-x (handler-bind [] i64-y)))
    (return 0)
    

    — which compiled before this lane and compiles again now — was refused with "return is not allowed inside handler-bind yet", pointing at a line with no handler-bind within sight of it. A valid program refused for a reason that is not in the program.

  • loops, the same window via loop: a leaked Lrecur made an invalid break answer "the nearest loop is a (loop …), which answers with the value of its body" instead of "break is only allowed inside a loop". No bad accept, a thoroughly misleading refusal.
  • defer_block, message text only, and leaked with loops.

So the subset was replaced by the whole record. trial now restores every mutable field of ctx — the three above, the six from round two, and defer_ok, tail and outer_what, which would self-heal on their own and are restored anyway, because "this one cannot currently leak" is precisely the reasoning that produced two rounds of leaks. The destructuring is closed and carries [@warning "+9"], so adding a field to ctx stops trial compiling until somebody decides about it. Verified that the guard guards: removing one field from the pattern by hand fails the build, naming the field.

One thing is deliberately not restored, and it is on env rather than ctx: an abandoned trial that lifted a function out of an fn literal leaves it in env.lifted. That is dead and harmless — the names are fn/<owner>/N handed out by count, so the live pass gets fresh ones and nothing refers to the orphan — and it rides into the module as a function nobody calls. Left because env is the program's table rather than this form's, and rewinding it would mean deciding what else on env a trial may have touched; the one piece of env state that genuinely needs rewinding, the generic instantiation cache, already rewinds itself in instantiate.

All five symptoms pinned — the two accepts, the shadow, the unknown name, and the loop diagnostic.

2. The trial reconsidered literals. Written up under the join rule above. The short version: (+ u8-thing 300) compiled, at i32, answering 555. The decision was literals-unchanged and now the code says so, by kind rather than by hope.

3. Three globals collided with the prelude. The dogfood batch added u8-max, u16-max and u32-max as prelude ~defconst~s while this lane was open, and the acceptance program had defined its own. The textual merge was clean and all three acceptance rows died on "defined twice" in the merged tree, which is precisely the failure a per-lane dune test cannot see. Every global and function in test/programs/widening.flan now carries a w- prefix, and the rows were re-run in a trial-merged tree rather than only on the lane.

Collisions with the lanes that landed underneath

Three, each read by hand rather than trusted to the auto-merge:

  • The diagnostics lane kinded expect's mismatch as check/type-mismatch so a call-argument site can recognise it. Its wording and its mechanism win; numeric_note rides on the same message, because a reader who has just been told i64 and i32 are different types needs telling in the same breath which direction needed nothing.
  • The struct lane added check_bare and positional_struct. No overlap: it calls expect, this lane added an arm inside it. The intersection — a struct literal whose field initialisers widen — was compiled and run on both backends by hand.
  • The int/float alias lane pinned (+ int-var i64-var) as a type error, with a comment saying the pin was written as identity so it would survive whatever the widening table grew into. It was not written that way — it pinned a refusal and a message — and it is the one refusal pin in the suite this lane makes legal. Rewritten to pin identity for real: the mixed form is accepted at i64 under int exactly as under i32, and the narrowing back into int is still refused, naming i32 because that is what int erases to.

Stale claims elsewhere, and one left alone

runtime/flan_dyn.c's flan_dyn_need_f64 note and runtime/flan_dyn_stub.c's arithmetic note both said the typed language has no implicit widening at all. Rewritten, and the rewrite is not a hedge: the typed language does widen an integer into a float now, but only the exact ones, and the dyn box carries integers at i64 — the one width that reaches no float on the lattice. So both boundaries refuse exactly what they refused, for a reason that is now stated correctly.

web/index.html (two places) makes the same stale claim. Left alone deliberately: the website has its own rewrite lane, and a marketing page is not the place for this lane to be making edits it cannot test. Flagged here so that lane picks it up.

(agent/start) lost its argument, 2026-09-20

Four notes from the lane that made the socket path optional and bound it before main. Three of them are about ground this lane deliberately did not take; the fourth is a line the author can delete at leisure.

sand.flan can drop its socket path

(agent/start "/tmp/flan-sand.sock") at sand.flan:125 still works and always will — the explicit form is not going anywhere. But the path was only ever a value nothing read under flan dev, because the daemon overrides it through FLAN_AGENT_SOCKET, and the zero-argument (agent/start) now does the right thing in both places: the daemon's socket when there is one, and an announced /tmp/flan-agent-<pid>-<clock>.sock when there is not. Changing that line is a one-word edit whenever the author feels like it; this lane does not touch sand.flan.

With the constructor below, sand.flan could delete the call outright — it calls (agent/poll) in its frame loop, which is the condition. That is a bigger claim than a shortened line and is worth making deliberately.

Auto-start reaches as far as the linker does, and no further

vendor/agent/flan_agent.c's auto_start constructor binds FLAN_AGENT_SOCKET before main, so a program under flan dev needs no (agent/start) at all. What it cannot do is reach a program that never mentions the agent: Reach prunes a package nothing calls into, so an executable with no (import agent ...) — or one that imports it and calls nothing — does not link the file the constructor is in. There is nothing to run.

So the true scope is: a program that calls (agent/poll) or (agent/wait) and has dropped its start call. That is the ceremony the feature was asked to remove, and it is removed. Full invisibility — a dev build that links the agent because it is a dev build, whether or not the source says so — needs the package force-linked from Load/Build, which are files this lane did not own and a decision about what --dev means rather than about the agent.

The constructor is also not --dev-only, because nothing in vendor/agent/flan_agent.c can tell the two builds apart: the dev runtime is linked either way and there is no weak symbol to ask. A release binary that links the agent and is run with FLAN_AGENT_SOCKET set in its environment therefore binds a listener it would not have bound before. Only flan dev sets that variable and it never runs release builds, so this is a sentence about the shape of the gate rather than an observed problem — but it is the one behavioural difference outside the dev loop and it should be said.

The way it would be felt is theft rather than noise, and that is worth spelling out: start_on unlinks the path before binding it, because a stale socket from a previous run is the ordinary case. So if FLAN_AGENT_SOCKET ever leaks into a shell's exported environment — a person exporting it by hand to drive a program with nc, a terminal opened from a daemon's child — every agent-linked program started from that shell takes the path away from whoever bound it first. The earlier program keeps an fd on a socket with no name and goes silently unreachable: the daemon that was talking to it now reaches the newcomer. Before this lane the unlink was reached only by an explicit (agent/start ...), which is a line somebody wrote; now any agent-linked program run in that environment does it before main. The gate is the same variable either way, so the fix, if this is ever felt, is a narrower gate rather than a narrower unlink.

The daemon's "has not called (agent/start …)" note is now unreachable

install_note (lib/dev.ml:789) and the describe branch at lib/dev.ml:1085 say, of a RUNNING program whose socket is not bound, that a redefinition installs at its next (agent/poll) and not at all if there is none. For a program that links the agent that cannot happen any more: the constructor binds before main, so by the time any editor can ask, agent_bound is true.

It was true of exactly one thing, and the constructor is what removed it: a merged session whose program links the agent, where the ring is reachable in-process from the first instant while the socket is not bound until (agent/start ...) runs. Bound before main, that window is gone. Going through the other three shapes leaves nothing:

  • merged, program links the agent — the socket is bound before main, so agent_bound is true by the time any editor can ask. This is the window above, closed.
  • merged, program does not link the agent — there is no flan_agent_request in the process and no socket either, so the delivery is refused with "cannot reach the program on …" and never reaches install_note at all. Pinned as of this lane by programs/dev-noagent-running.flan and its row in test_dev.ml, which also holds that the session survives the refusal.
  • two-processtwo_process kills the child and =failwith=s when the socket never appears, so a program with no agent has no session to be sent anything.

So the branch at lib/dev.ml:789 and the describe arm at lib/dev.ml:1085 are unreachable, not merely unexercised. Retiring them is the author's call over a lane that merged days ago, not this one's — they are left in place, saying a true thing about a state nothing can now be in.

Two rows nearby are about different sites and should not be mistaken for cover: the dev-noagent.flan row asserts the daemon's own stderr warning, said by the accept loop once the ten-second deadline is behind it, and the late-agent row asserts the note's absence.

The agent socket under the daemon is still the temp directory's problem

start_on now stashes the path it bound and unlinks it three ways: an atexit for an ordinary exit, and by hand in die_now and orphan_die, which both leave by _exit and skip the atexit chain deliberately. That covers a program run on its own, a program aborted out of the break loop, and an orphan whose daemon died.

It does not cover an ordinary flan dev session ending, and cannot: the two-process daemon kills its child with SIGTERM and the merged session leaves by Unix._exit 0, neither of which runs an atexit. The socket sits in /tmp/flan-dev-<pid>/ and goes when that directory goes — which is the item above, "The daemon leaves its temp directory behind", still open. No separate fix is wanted here; the session-end cleanup that item asks for takes the socket with it.

builtin/, the reserved qualifier, 2026-09-20

The author's decision, in the author's words:

"the full spelling is fine, I like that."

This closes the dead end disclosed the same day by "Shadowing a builtin, 2026-09-20": a defn named after a builtin wins program-wide inside its own file, and before this there was no remaining spelling for the thing it had taken over, so a definition that meant to wrap a builtin was unbounded recursion with no diagnostic. builtin/len is the builtin len, whatever else the file has decided len means, and it is legal whether or not anything is shadowed — a spelling that only compiled while some other declaration existed would be one nobody could write down in advance.

The resolver

Two interceptions, both at the very top of the dispatch they sit in, and each strips the prefix and re-enters the same function with one flag set:

| _ when not qualified && qualified_builtin name <> None ->
  let bare = Option.get (qualified_builtin name) in
  if not (Hashtbl.mem builtin_set bare) then not_a_builtin loc bare;
  named_call ~qualified:true ctx ~want loc bare args

named_call gains ?(qualified = false) and the shadowing guard beneath it becomes not qualified && shadows_builtin .... That flag is the whole of the mechanism: a qualified call has already said which of the two readings it means, so there is nothing left for shadowing to decide, and every arm below sees the bare name — which is why (builtin/len 1 2) is refused with exactly the sentence (len 1 2) would get. It cannot loop: builtin/builtin/len strips once and is then refused by name, because builtin/len is not in builtin_set.

var gains the same arm, for the builtins that are names rather than calls — true, false, nil, None, context/allocator, context/temp. The last two fall out for free: stripping one prefix off builtin/context/allocator leaves a name the match already has an arm for.

The one asymmetry, and it is load-bearing. var's qualified path must refuse where the call path falls through. A qualified name that gets past the value arms is in builtin_set but is call-only, and letting it fall into lookup~/~globals~/~fns would answer builtin/len with the address of the very definition the qualifier was written to escape — the feature inverted, silently. So a guarded | _ when qualified -> arm sits above the catch-all:

builtin/len is the builtin len, which is a call and not a value — a builtin
has no address to pass. Write (builtin/len ...) at the call, or wrap it in a
defn to pass that

Why / and not (builtin get)

The spelling is the package qualifier's, deliberately. A reader who knows that rl/draw-fps is draw-fps from the package imported as rl already knows what builtin/len is and needs no second syntax. What makes it unambiguous is that builtin is reserved rather than resolved: every qualifier in a finished program comes from Load.qualify, and every qualify takes its alias from an import form, so refusing that one alias is the whole of the reservation — there is no other door.

The refusal is at the top of Load.import, before the package is read, which also covers a package importing one under that alias since every import goes through that function:

builtin is a reserved qualifier and cannot be an import alias: builtin/name
always means the compiler's builtin, which is how a program reaches a
builtin it has shadowed. Import this package under another alias

It is the alias and not the directory. The task that asked for this said "a package directory named builtin must be refused at import", and the code says something slightly narrower, because the alias is always written out — (import rl "vendor:raylib"), parse.ml's two-element form — and a directory never becomes a qualifier on its own. A package whose directory is called builtin imports fine under any other name and collides with nothing; (import builtin "anything") is what is refused. The directory is not named in the message either: it has been resolved to an absolute path by then, and the caret is already under the import form, which carries the path the reader wrote.

And from the other side. (defn builtin/len ...) reads — / is an ordinary symbol character — and would land in env.fns under a name nothing could ever call, since the prefix is stripped before any table is consulted. Check.collect refuses any declaration whose name carries the prefix, over Ast.declared_name so it covers every declaration kind at once:

builtin/len cannot be declared: builtin/ is a reserved qualifier, so a name
spelled with it reaches the compiler's builtins and never a declaration —
nothing could call this one

The reader, and builtin/+

No exception was needed. reader.ml's is_delimiter makes / ordinary and says so in its own comment (rl/draw-fps is one symbol), and the operator characters are ordinary for the same reason + is a symbol at all. The one path that could have taken builtin/+ apart is the number reader, which takes a token starting with a digit or with -~/~+ followed by a digit, and builtin/+ starts with b. So it arrives as one symbol and there is nothing to document as unreadable. (builtin/+ 1 2) is 3 in a file whose + answers 99, and it lowers to the Add prim rather than to a Call.

The refusals for a qualifier that reaches nothing

Its own kind, check/unknown-builtin, and the did-you-mean is over builtin_names alone — not over the program's own names. The reader wrote the qualifier, so they were reaching for a compiler name, and offering them a defn called lem would answer a question they did not ask. Every other did-you-mean in check.ml keeps the candidates it already had, and the dot-access diagnostic is untouched: nothing anywhere suggests a builtin/ spelling for a name that was written bare.

nosuch is not a builtin, so builtin/nosuch reaches nothing. The builtin/
qualifier reaches the compiler's own names and nothing else; an ordinary
function is called by the name it was defined under
lne is not a builtin, so builtin/lne reaches nothing — did you mean
builtin/len?
builtin/ needs a name after it — the qualifier reaches a builtin, as
(builtin/len v)

The warning now names the escape

shadow-builtin.flan:20:7: warning: get shadows the builtin get — every call in this program now reaches your definition — the builtin stays reachable as builtin/get

One sentence still, and the second half is the half the reader wants next: they are being told the name was taken over, and what is left is the thing they are about to go looking for.

The arm-scraper, which nearly broke twice

test_flan.ml reads check.ml's source to cross-check the builtin arms against Check.builtins, keying the two regions on the literal prefixes "and named_call " and "and var ctx ". So the optional argument had to go after ctx in varand var ctx ?(qualified = false) loc ~want name — and a leading one would have emptied that region and reported true, false, nil, None and the two context/ names as deleted arms. Both new arms are guarded and carry no string literal in the head, so the scraper skips them exactly as it skips the shadowing guard; nothing in that test changed.

Pins

  • test_flan.ml: builtin/len checks with nothing shadowed; with a two-parameter len in the way, the bare call at two arguments checks and the qualified one is refused at the builtin's arity ("len takes 1 argument, given 2") — the pair is refusable only under one reading each, so it cannot pass under both.
  • test_flan.ml: the self-referential wrapper (defn len [s string] i32 (builtin/+ 1 (builtin/len s))) checks and its body holds no Call ("len", _) anywhere, walked with Tast.walk — the difference between a wrapper and a loop, asserted rather than assumed.
  • test_flan.ml: builtin/+ with + shadowed lowers to Prim (Add, _); builtin/nil and builtin/context/allocator check; builtin/len in a value position is refused with the call-and-not-a-value sentence.
  • test_flan.ml: check/unknown-builtin as a kind, both of its messages whole (with and without the near miss), and the declaration refusal; plus that a bare typo is still answered bare ("did you mean len?") and never with a qualifier.
  • test_flan.ml, changed: the two shadow-warning messages, matched whole, now carry the escape clause.
  • test_acceptance.ml: programs/builtin-qualified.flan outputs 9\n5\n4\n99\n3\n — builtin/max unshadowed, the wrapper's 5, builtin/len's 4 beside it, the shadowed operator's 99, builtin/+'s 3. It is an ordinary corpus row, so the @x86 sweep compares both backends over it.
  • test_acceptance.ml: programs/import-builtin-alias.flan is refused, by the alias sentence and by the clause saying what the qualifier is for.

What was run

dune test in the lane's worktree, green. Per the batching policy the @x86 and @sanitize sweeps were not run here — the new corpus row is registered for the x86 survey through the existing programs/*.flan glob and will be compared on the next sweep.