test_dev.ml's abort asks were a bare Wire.send/Wire.recv pair against a daemon that the abort itself is killing. In a merged flan dev the daemon is the program: flan_agent.c's listener writes ok and sets aborting, and the break loop's next pass _exit(134)s from the program thread while the editor's reply is still being composed on the serve thread. Nothing orders the two, so the reply arrives or the socket closes, at random. When it closed, Wire.recv raised Closed, nothing caught it, and the test binary died with no FAIL line and every case after it unrun. Four runs out of four at the null-allocator trap. Both endings mean the same thing and neither is the assertion: the waitpid wait underneath each row is what says the program went. aborted answers None for the end that arrived as an exit; the three sites that abort a stopped or trapped program go through it. The fourth abort is refused by a running program and ends nothing, so it is left alone. trap_park's describe poll is guarded with the other answer: these traps park because flan_trap_hook is installed, so a socket closing there is the trap having ended the program instead of stopping it, which is the failure that row already names. And SIGPIPE is ignored for the watchdog's reason, so a send into the socket a dead daemon left behind cannot kill the binary silently from the write side.
97 KiB
- Stuff I've found
- Why do I need to call flan to open another window?
- I can't eval a toplevel defvar, need to eval-defun (C-c C-c)
- I can't eval a top level Vec
- 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
- Open, found while working the list
- Status, end of 2026-09-17
- Landed on dev-loop
- Evening of 2026-09-17 — the review, and four lanes off it
- The repeal, 2026-09-18
- The second round, 2026-09-18 — Pool, move-only, and the gen word
- End of 2026-09-18 — the hunt closed out
- Late 2026-09-18 — the last two lanes
- The dynamic half of item 4, finished
- Session close, 2026-09-20 — dynamic-first M1 landed
- M2 queue, decided with the author 2026-09-20 — in order
- handler-case, decided 2026-09-19
- Surface syntax discussion, 2026-09-19
- The x86 backend tracks LLVM -O0, decided 2026-09-20
- Per-form initialisation semantics on re-run, decided 2026-09-20
- Memory diagnostics on demand, decided 2026-09-20
- Review-batch findings, 2026-09-20
- A numeric cast opens a dyn box, decided 2026-09-20
- Follow-ups from the 2026-09-20 reviews
- A defconst is a compiler const, decided 2026-09-20
- The third element of a defvar decides, 2026-09-20
- Classes and generic functions, 2026-09-20 — M2 queue item 6
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 Flan’s 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
- dyn maps + keywords (interned, O(1) equality). Retires edn/Value after.
- Per-type descriptors: dyn fields in structs/conditions become markable.
- 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.
- 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. - Typed = and != grow strings: bytewise, length + same-pointer fast paths,
both backends, one survey program. Ordering stays refused. — LANDED,
daed039 - 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".
-
dyn if: truthiness (nil/false are false, all else true). Typed stays strict bool. — LANDED,
264765aReaches 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
ad0f1fband 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, whichad0f1fbhad 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.
- 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
- 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
495629fand the flags in931cf86— 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]:
closefrom 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.
- 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. - 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. - 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 buildnever 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.
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 ginitlost 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/36624d6e339078585a5dd95d156786bb0324bf53/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/36624d6e339078585a5dd95d156786bb0324bf53/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/36624d6e339078585a5dd95d156786bb0324bf53/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?andlenare 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 adefn'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:elsefor the arm everything falls through to.:elseand not Clojure's:default, becausematchalready spells "none of the above" that way and two words for it would be one too many.:elseis the last arm whatever order it was written in. - A miss signals.
(defstruct NoMethod [generic string value dyn])in the prelude, signalled witherror, 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-casearound the call is the shape, and a:elsemethod is the other answer. No restart is established at the miss, which is BoundsError's decision taken for BoundsError's reason.valueis the firstdynfield 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/36624d6e339078585a5dd95d156786bb0324bf53/b%20a under a generic /JosephFerano/flan/src/commit/36624d6e339078585a5dd95d156786bb0324bf53/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,:aroundandcall-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 generateddefngives 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.
niland floats as dispatch values.:elsecovers the nil case, which is the common one (class-ofanswers 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. - 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.redefinitionhas always emitted them, by going throughfinish; 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 untilNoMethodthere was no such struct a compiler-written body could build. It is not a bad read at run time — a descriptor label is local, soldrefuses 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_asmwrote them into.rodata, and a descriptor holds the address of its own offset table. A relocation in a read-only section is aDT_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 withreadelf -don a reload module from each backend: noTEXTREL, 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-exprnever 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.
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.