120 KiB
Flan
- Language surface
- if let
- when as a value, and get as a checked lookup
- str and String
- Any typed container crosses into dyn as a view
- Dyn unless annotated
- Dynamic-first, and the dyn half of the language
- The dynamic paths mimic Clojure, the static paths mimic Odin
- Arithmetic semantics do not fork across the two spaces
- The return slot stays mandatory
- A return type is read off the body only when the slot says
_ - An if's or match's arms meet at one join, whichever is written first
- def, defonce and defconst are the three forms
- The third element of a defvar decides
- A macro has a parameter list, and [args] is the first argument
- Map destructuring in a macro's parameter list
- A macro fails at its call site in its own words
- gensym's counter restarts in a second module
- A quasiquote inside a quasiquote nests
- Bit operators are && || ^^ ~~ in .fln (decision 123)
- A form the prelude relies on is built in; a form only programs use is a macro
- Macros are imported from a package
- A prelude function may call a prelude macro
- The expander design: running a macro means dlopening it
- (comment …) is a prelude macro
- inc, dec, ++ and –
- Type-limit constants
- The two byte fills: (filled BYTE) and (dead-beef)
- There is no literal for an infinity or a NaN
- A u64 constant above 2^63 cannot be written in decimal
- A wide literal's follow-ups: an enum member, a dyn want, a macro
- A wide literal written inside a quasiquote comes back as an i64
- {.row .col} binds same-named locals
- An empty body where a body is optional
- () as a unit value in expression position
- // for forced truncating division
- builtin/ is a reserved qualifier
- println is variadic
- The six comparisons take two operands or more
- dotimes counts from where you say
- slice takes three arities, and at/slice reach a string
- One slice; as-slice is gone
- The count is length, and len is a name a program can have
- Counting abandonments
- The randomness surface
- A value-producing array constructor
- (array 4 T) is the zeroed array constructor
- A defining form owns what happens on a re-run
- defenum autoincrement
- Every mapped raylib enum carries a Flan-side prefix
- ML-style patterns
- match over numbers and strings
- match over enums
- defdata is the tagged sum, defunion is C's untagged one
- A defdata carries its tag, and case order is part of the contract
- An imported defdata keeps working across a package boundary
- (Result T E) and try
- (Handle T) and the pool
- drop, and unwind-protect with it
- defer may be written in a let
- A return runs its defers before it computes its value
- edn reads into a struct and answers a dynamic value
- A data file's struct is derived while the program is compiled
- Assets are embedded at compile time
- slurp and barf are the file surface
- Writing a file is desktop-only and signals on web
- Conditions get a parent link, not class inheritance
- Can a condition be a class?
- handler-case
- A restart that abandons the evaluation
- find-restart and compute-restarts
- into fuses at compile time because it is a macro
- loop and recur
- break and continue, with loop labels
- The second tier of the standard library
- The Clojure patterns deliberately not copied
- A pointer from C needs a length before it can be indexed
- A string cannot be returned from C
- Model, Mesh and FilePathList have a defstruct
- A callback is the other direction of the FFI
- raymath is written in Flan, because static inline has no symbol
- rlgl's matrix stack is bound
- examples/core-input-virtual-controls.flan does not build
- cstring as a type
- rune as a type
- Two function types: Fn captures, CFn cannot
- Escaping closures, allocated on the GC side
- CFn and C's calling convention
- defclass is a named dyn map with a shape tag
- Class features deferred, each with its reason
- update-instance-for-redefined-class, the user hook
- The module system stays directory-as-package
- A name imported through a package keeps the inner alias
- A package cannot mark a name private
- A private scoped to one file of a directory package
- A struct version word, so a redefined layout keeps working
- An indented surface beside s-expressions
- Lambdas are written with =>
- Indices separate like vector elements
- Calls without parentheses
- RET inside an open call puts the closer at the statement's column
- not is a prefix word
- A let takes several bindings on indented lines
- .fln has no loop or recur (decision 122)
- A .fln chain may mix < with <=, or > with >= (decision 124)
- Hard-coded code in messages is still paren syntax in a .fln file
- The shims in sand.flan can go
- sand.flan's game-data.edn initialiser no longer aborts the headless import
- An error in a macro's body is reported where it was written
- A declared name may carry the $ sigil
- not= as a spelling of !=
- Checker
- Checking a wide fold of let operands is slow
- A dyn value takes .field and [:key]
- A slice from a C pointer, and a pointer cast
- A pointer crossing into dyn
- A _ body that returns an fn literal
- The ownership flow analysis is repealed
- Move-only is gone with it
- Implicit numeric widening is legal; narrowing stays a hard error
- abs is one generic, and a bound joins to the wider type
- A conversion is legal at a bounded variable when it is legal at every type the bound admits
- The Ptr and union arms of the fill boundary are relaxable
- A compound constant expression at a bounded type variable
- Generics by monomorphisation, checked abstractly, with where predicates
- A type variable takes a $ sigil
- A type variable may be written with its sigil at a use
- Milestone 5 was mostly already there
- hashable? gates the type and not the operations
- The runaway instantiation cap names the chain
- Generic types
- A value predicate over a length parameter
- A program is one compilation, so a generic's body is always visible
- Collapsing the prelude buys 27 to 15, not 27 to 6
- A bare {.field v} takes its type from the position it stands in
- (Cell 1 2) is positional, and its arity is exact
- Shadowing a builtin is legal, and the definition wins
- int and float are builtin aliases
- A defconst is a compiler const
- Container globals start zeroed, and a defconst container is refused
- A numeric cast opens a dyn box
- nil crosses at (Option T) and nowhere else
- Typed = and != reach strings
- dyn truthiness in if, when, cond, and, or, not and while
- and's last operand gets a misdirected caret
- A typed container crosses into dyn as a view
- A view of a Vec goes stale at the push, and the warning is at the push
- A live view at the push, detected cheaply
- Catching a use-after-release statically
- A with-allocator escape check
- (vec-new [u8]) is refused
- An array literal cannot say it is [f32]
- A let binding takes no type annotation
- A read-only slice type
- (slice d 1) over a dyn vec traps where the typed Vec's works
- (slice "abc" 0 99) is not refused at compile time
- An owning temporary as into's source leaks
- Notes on the type-mismatch errors
- An error is a value, and there is more than one of them
- An index converts from a narrower integer and never from a wider one
- There is one top-level namespace
- A u64 literal is its 64-bit pattern
- A folded constant does not skip the range check
- A defn must state its return type, and an unknown one says so
- Anything that binds a name or alters control flow is recognised explicitly
- The escape was real: a value the compiler builds trips no function-value refusal
- Function values, with no capture
- CFn in a struct or a fixed array
- Structural compatibility is identical layout
- Two refusals suggested something that does not compile
- Backends
- The x86 backend tracks LLVM at -O0
- The x86 backend is the dev daemon's default
- A crossed reload is refused by an ABI marker
- The x86 redefinition emitter
- Conditions, bounds checks and the cell on the x86 backend
- Reading (uninit) before writing it is undefined behaviour
- f64 to i64 out of range, and INT64_MIN / -1
- Rt with an aggregate return was never a gap
- An assignment is whole or it never happened, on x86 too
- A global initialiser builds an aggregate through the same temporary on x86
- A u64 converted to f64 is signed on x86
- An aggregate built in place never reads its own destination
- The aggregate temporary is an unrooted buffer while it is filled
- Marking through a descriptor an x86 reload module emitted
- A string literal crosses to C uncopied
- Frame descriptions are gated on –debug
- A !DILexicalBlock per Let
- UBSan sees no Flan code, and no flag changes that
- A JS backend is a dialect, not a second machine
- The JS backend is held behind the dev loop
- The web target does not reach four things
- A package's .c files can be addressed to a target
- The IR and the disassembly are not annotated with the source
- A temporary allocator, wiped each frame
- Runtime
- Heap quarantine in dev
- An index out of range is a condition
- No restart is established at the failing index
- ArithError signals and dies, with no restart offered
- slice-from's runtime refusal names the promise
- A dying program uses _exit
- A Map's bounds check and the stale-container failure still die
- Six trap paths park instead of killing the session
- bytes copies, string constants trap, and a segfault parks
- Allocators, (Vec T) and StorageExhausted
- A stale slice reads poison in a dev build
- The allocator's budget is not in the spec
- The Vec header is not the size the spec fixes
- arena-destroy under a live view reads freed memory
- Map removal costs a backward-shift loop
- The Map is slower than CPython's dict at a million entries
- dyn maps and interned keywords
- Per-type descriptors make a dyn field in a struct markable
- A dyn read out of a place is rooted while a sibling operand runs
- A redefined defclass migrates its instances lazily
- A class registry keeps one slot list per class, not one per layout version
- A spin is not patience: the registry's slot read
- The 4K result cap is not a transport buffer and stays
- The snapshot copying and generation stamping stay
- The seqlock's losing race has no test
- The snapshot generation's racing stale claim has no test
- A choice made at an outer break survives a nested one
- SNAP_MAX and SNAP_NAMES are read rather than tested
- Probing for an interior overrun under memcheck
- ASan does not see an uninitialised read; valgrind does
- The memcheck half of the allocation registry
- The leak question across the corpus
- trap_oom has no site
- A formatted number outlives its frame
- A shift count is bounded two different ways
- The linked-list frame beat an array with a stack pointer
- runtime/flan_dyn_stub.c is dead
- A destroyed arena always traps, even after its record is reused
- A mixed array literal with no want is a dyn vector
- Dev loop
- A _ caller whose type follows a redefined callee
- The dev loop, step 1: the reload primitive
- The dev loop, step 2: a name the process was never built with
- The dev loop, step 3: the agent installs at a frame boundary
- The compiler is a thread inside the program
- Transport and code generation were independent, and transport was the larger prize
- Re-runnable main after the window closes
- An accepted re-run reads as running
- C-x C-e installs a top-level form
- C-x C-e answers against a parked program
- Evaluating a def assigns
- An evaluated expression that signals says so at once
- The first evaluation no longer stalls behind the agent socket
- The parked-program note is said once per park
- A session ends when no editor has held the socket for a grace period
- Parked orphans exit with their daemon
- The daemon survives a client that closes mid-reply
- The daemon leaves its temp directory behind
- (agent/start) takes no argument, and binds before main
- The daemon's "has not called (agent/start …)" note is unreachable
- The allocation registry
- The shadow stack
- The x86 backend pushes shadow-stack frames
- A restart is not a transaction
- The break loop's display pass
- The break buffer opens by itself when the program stops
- The condition itself is on the wire
- Reading a value compiles nothing
- The inspector holds a value
- The watch table stays pushed
- A watch over a struct or a slice
- Two ways to root a walk
- C-c C-c on a generic installs its instantiations
- Signature generations and stale-caller warnings
- An expression's module is unloaded unless it hands out a constant
- A redefinition delivered while parked installs on the next re-run
- A dyn value from eval-expr never reaches the reply's value field
- Memory diagnostics on demand
- A debug tracking allocator over the raylib boundary
- –dev –sanitize was unbuildable, and nothing built it
- test_dev's abort rows raced and killed the binary
- A transient signal 11 on a globals daemon
- test_dev daemons fail to bind under load
- A test binary that hangs is killed by its own alarm
- A test binary leaves nothing in the temporary directory it was given
- A forked acceptance failure reaches the exit status
- An x86 dev session's read of a dyn global after an allocating thunk failed once
- Editor
- C-c C-l on a generic needs a session to find its copies
- The syntax table and the font-lock lists are read off the parser
- A user-defined macro is highlighted from the live session
- A discarded form is drawn as a comment
- A binding vector indents name-under-name
- Every tracked .flan file reindents to itself
- C-c C-i inspects the expression at point
- An evaluation's value is shown at the end of the line and echoed
- Two streams and one tool list
- A breakpoint is marked from the editor, without editing the buffer
- A backtrace and a frame's locals have an editor surface
- Hex, binary and an address on a primitive in the inspector
- A defclass is not on the definitions list as a type, and a sum's cases are not drawn
- A flycheck checker, and a structured JSON report
- defclass slots take types, checked on write
- println takes up to a second to appear
- set writes a class slot; put is for maps
- A session eval reported (CFn [] ()) does not cross into dyn yet
- A digit does not take the restart RET takes
- Evil takes the keys in the other Flan buffers
- The stack lists prelude frames
- C-c C-c reports one error, not every error in the form
- There is no stepper
- A NaN cast says "does not fit", which reads as too big
- The condition buffer cannot jump to the source
- loop's bindings should be sequential, like let's
- A session should start before a program compiles
- compilation-mode steps over the notes
- Docs and the repository
- The reference page says what the language is
- @page was green over a needle it could never derive
- The reference, the tutorial, the how-to and the explanation
- dune test stays fast and the slow checks stay opt-in
- A lane runs dune test and nothing more
- A check nothing runs rots, so every check has an alias and CI runs them
- Compiler messages are written for a first-timer
- The diagnostics pass
- Every diagnostic carries a stable kind at the end of its first line
- docs/BUILT.md still describes (Handle T) and the pool as built
- The daemon still tells an editor the x86 backend pushes no frames
- web/index.html still claims there is no implicit widening
- plan.org's Types section lists a predicate that no longer exists
- plan.org's Data model section still describes move-only containers
- plan.org cites the wrong mechanism for jank's relinking bug
- docs/SPIKE-GENERICS.md lists landed work as remaining
- Two citations in spec-memory.md do not land where they say
- The sand hash is quoted as prose in five places besides its assertion
- sand.flan is two programs
- The brush is embedded, not loaded from a path
- A package under test/programs needs a glob line in four places
- The macro programs are not in the sanitizer sweep
- The mutation pass has not been re-run
- bin/main.ml spells the compile pipeline out by hand
- Build.executable returns only its output path
- The 2MB OFL font is not vendored
- old-ocaml/ and the built executables are untracked on purpose
- A lane that stops mid-repair says which pieces it ran
- Examining branch emacs-batch-a63bd
Every decision, open question and known gap, one ** heading each under the
subsystem it belongs to. A DONE entry says what was decided and what that rules
out, in a few lines — never the argument and never the measurements. Where the
reasoning will not fit, it is in docs/BUILT.md and the entry is one line
pointing at it. A CANCELLED entry carries the one-line reason, because an idea
rejected without a record is an idea that gets re-proposed.
Language surface
NEXT if let
Decided 2026-09-26 (126), Rust's spelling: if let Some(g) = left plus a block tests
the pattern and binds g in that block only; elif=/=else follow as for if. Any
match pattern may stand where Some(g) is. Reads to a two-arm match.
NEXT when as a value, and get as a checked lookup
Decided 2026-09-26 (125): a when whose value is used gives Option(T), Some of its
body when the test holds and None otherwise; as a statement it is unchanged.
get(xs, i, …) on an array, slice or Vec gives Option(T) instead of trapping on an
index out of range (negative included), one index per dimension. Both typed and dyn.
NEXT str and String
Decided 2026-09-25: the typed read-only text is str (the rename from string is
done; string is refused with the fix); String is new, owned, growable, always valid UTF-8, through an allocator. The prelude's text
builders return String; (Vec u8) stays for raw bytes. Text is UTF-8 everywhere and
a character is a code point; dyn gets a character tag, so \\I prints as \\I.
Dyn text stays immutable, with chars and text converting to and from a dyn
vector of characters; length and indexing count characters on dyn text and bytes on
str. Waits on the dyn-unless-annotated design.
DONE Any typed container crosses into dyn as a view
CLOSED: [2026-09-26] A str element reads as a copy and is never written, an aggregate element is written through its own view, a float element takes an int only when it holds it exactly (a class's float slot's rule), and a u64 above the largest i64 traps when read. A view of storage its own dyn global's initialiser built is refused. Rules out copying at the crossing, a dyn big int for u64, and any check in a release build.
NEXT Dyn unless annotated
Decided 2026-09-26, replacing the plain rule: number, bool and char literals are typed, their type inferred from their uses inside the function (never across functions); an unconstrained integer literal is int (i32) and a float literal float (f32); uses that disagree are refused with a request for an annotation. Vector, map and text literals are dyn unless something typed wants them. A typed value is boxed where it goes into dyn, and a dyn unboxed (checked) where typed code needs it; typed beside dyn in an operator gives dyn. Dyn integers stay i64 and dyn floats f64.
DONE Dynamic-first, and the dyn half of the language
CLOSED: [2026-09-20]
An unannotated parameter or return is dyn: a NaN-boxed value over a mark-sweep
heap, with --no-gc refusing residual dyn by location. The typed language is
unchanged underneath it.
DONE The dynamic paths mimic Clojure, the static paths mimic Odin
CLOSED: [2026-09-19] A tiebreaker for features, not a licence for meanings. A question the dyn side has that Clojure has answered takes Clojure's answer; on the static side Odin's; for conditions, Common Lisp. Rules out using it to settle anything about what an operation computes.
DONE Arithmetic semantics do not fork across the two spaces
CLOSED: [2026-09-20]
One operator, one meaning, both sides. / truncates toward zero and % follows
the dividend everywhere. Rules out Clojure's flooring mod as a dyn-side-only
behaviour of %; a flooring mod would be a second, separately named operation
available to both.
DONE The return slot stays mandatory
CLOSED: [2026-09-19]
A defn writes its return type, and unit is (). The parse ambiguity a missing
slot would open is real, and () does not collapse into dyn. Rules out the
optional return slot.
DONE
A return type is read off the body only when the slot says _
CLOSED: [2026-09-25]
The body's own type, never a call site's; parameters are never inferred, and a
cycle among _ functions is refused by name unless it gives (). Rules out
use-directed inference.
DONE An if's or match's arms meet at one join, whichever is written first
CLOSED: [2026-09-25]
Each arm is asked the other's type first; only a refusal meets at the join
(lossless widening, const, dyn beside a dyn value), the same for _ exits.
Rules out the first arm's type refusing a wider second arm.
DONE def, defonce and defconst are the three forms
CLOSED: [2026-09-20]
def is Common Lisp's defparameter and re-initialises on every run; defonce
is CL's defvar under Clojure's name and keeps its value; defconst is folded.
A def's initialiser is always lifted, zero and literal included, so editing the
form and pressing C-c C-c reaches the same storage. Rules out the
value-dependent defvar that was sketched — "re-initialise only if it would come
out different" was never needed: a dev build emits a defconst as a mutable
global, so a folded constant is tunable live without any dependency tracking.
DONE The third element of a defvar decides
CLOSED: [2026-09-20] A type there is the zeroed static global; a value there is a dyn global initialised from it. The same dispatch the parameter vector already makes. A symbol that is neither gets a refusal naming both readings.
DONE A macro has a parameter list, and [args] is the first argument
CLOSED: [2026-09-20]
A defmacro takes real parameters with destructuring and & variadics, checked
at the call before expansion; the whole argument list is spelled [& args].
Rules out a legacy mode where one bare parameter keeps the old meaning, which
would make [a] and [a b] mean unrelated things.
CANCELLED Map destructuring in a macro's parameter list
CLOSED: [2026-09-20]
A macro's argument is a Form, whose map case is a flat run of alternating forms
with no field names, so the pattern cannot be translated without being given a new
meaning and none of the candidates is obviously the one anyone wants. Vectors and
& are the 95% case and are built.
DONE A macro fails at its call site in its own words
compile-error is a builtin reachable from a macro body, so a macro reports what
is wrong where it was written rather than aborting the compile with no location.
The prelude's own unless has not been converted and still answers a bare
undefined name.
DONE gensym's counter restarts in a second module
CLOSED: [2026-09-25]
The counter is C data in the runtime (flan_gensym_n), and lib/macro.ml writes
the compiler's own count into the module before every macro call and reads it
back after. It counts across every module a compiler process loads — each round,
the program's module, and every expansion in a session. Rules out a counter per
module, seeded or not.
DONE A quasiquote inside a quasiquote nests
CLOSED: [2026-09-25]
Expand.quote counts depth the way SBCL's *backquote-depth* does: an unquote
belongs to the innermost quasiquote and ~~x reaches out two levels; deeper
forms come back as data. A macro's answer is desugared again, and a top-level
expansion that defines a macro re-runs the expander, in a build and in a session.
~~@x splices an unquote per element, SBCL's unquote*. There is
no ,',x, since quote takes a symbol, and a macro defined by an expansion is
not exported from a package. docs/BUILT.md, "Quasiquote runs before the walk".
DONE Bit operators are && || ^^ ~~ in .fln (decision 123)
CLOSED: [2026-09-26]
Tighter than a comparison, looser than a shift, && then ^^ then ||
(Python and Rust), so x && mask = 0= tests the masked bits. Integers only, a
bool refused toward and=/=or=/=not; a dyn shift count outside 0..63 traps.
~~ is one token, so a nested .fln unquote is ~(~x); the paren reader keeps
~~x as unquote twice and reads ^^ as a name. Rules out C's precedence.
DONE A form the prelude relies on is built in; a form only programs use is a macro
CLOSED: [2026-09-25]
cond, when and dotimes are special forms in parse.ml; inc, ++, into,
unless, until and comment are prelude macros.
DONE Macros are imported from a package
The old refusal claimed collecting a package's macros needed a second import
resolver at the Form level. It did not: the file being compiled is parsed before
Load runs too, so Load.program takes forms and uses the one resolver that
always existed.
DONE A prelude function may call a prelude macro
It was a cycle, not an ordering — the arity error came from the Check.program
inside Macro.compile, where expansion is off. "A prelude macro may not call a
macro" still stands and names itself when violated.
DONE The expander design: running a macro means dlopening it
There is no interpreter, so the compiler builds a shared object and dlopens it into itself. A call inside a quasiquote is output, not a compile-order dependency, and quasiquote is desugared before the walk, which is load-bearing.
DONE (comment …) is a prelude macro
CLOSED: [2026-09-20]
A macro's arguments are raw Form and are never checked as expressions, so what
is inside never has to be a program — it obeys the reader's rules only. #_ is
the other spelling and they are not rivals: #_ discards one form and works in
argument position.
DONE inc, dec, ++ and –
CLOSED: [2026-09-20]
Four prelude macros, generic for free. A word for the pure pair and C's
punctuation for the mutating pair. (++ PLACE) expands to (set PLACE (+ PLACE
1)), so a place with a side effect in it is evaluated twice — not fixable without
a reference type the language does not have.
DONE Type-limit constants
CLOSED: [2026-09-20]
i8..i64 and u8..u64 max and min, f32=/=f64 max, min-positive and epsilon,
each carrying its type. Kebab and the type's own name — i32-max, not INT_MAX.
There is no f32-min, because what a caller wants is the smallest positive value
and the name has to say which.
DONE The two byte fills: (filled BYTE) and (dead-beef)
CLOSED: [2026-09-20]
Two builtins rather than one — "why not both?" — spelled the way (zeroed) is,
taking the type expected of them, so a place is filled with set and there is
no place-taking form to learn beside it. A pattern's ascending bytes are its
big-endian bytes, which is how the hex literal reads. Two and not one with a
wider operand because the intrinsic takes a single repeated byte: the byte fill
is one instruction and the four-byte pattern is a loop on both backends.
DONE There is no literal for an infinity or a NaN
CLOSED: [2026-09-25]
f64-inf, f64-nan, f32-inf and f32-nan are names the checker supplies
(Check.special_float), reached only after every local, global and function has
missed, so a program's own binding of one wins. Negative infinity is
(- f64-inf). Rules out Clojure's ##Inf reader literal.
DONE A u64 constant above 2^63 cannot be written in decimal
CLOSED: [2026-09-25]
An integer written at or above 2^63 — a decimal up to 2^64 - 1, or hex with the
top bit set — reads as Form.UInt, its pattern and its spelling. It is accepted
where the type is u64, a (u64 ...) cast included, and refused everywhere else
in the spelling it was written in. Hex with the top bit set was accepted as a
negative at any integer type before this; it is refused now too. A cast's
integer literal that does not fit
i32 is checked at the cast's type; one that fits keeps the i32 default, so
(u32 -1) still means what it did. A wide literal passed to a macro as an
argument comes back wide: it crosses as an Int with a token in the unused
second payload word (Expand.wides). Rules out a second integer case in the
prelude's Form.
DONE A wide literal's follow-ups: an enum member, a dyn want, a macro
CLOSED: [2026-09-25]
(defenum E [A 0xFFFFFFFFFFFFFFFF]) gets the enum range refusal in the spelling
written. A wide literal where a dyn is wanted names (u64 ...) and says the dyn
holds it as the i64 with the same bits. A wide literal passed through a macro is
refused or accepted exactly as it would be unexpanded.
TODO A wide literal written inside a quasiquote comes back as an i64
(defmacro w [] `(+ 1 0xFFFFFFFFFFFFFFFF)) expands to (+ 1 -1) and prints 0:
Expand.quote builds (Form.Int {.i ...}) from the pattern, and a Form built in
Flan has no way to carry the token an argument crosses with. At a u64 want the
pattern is the right value, so a refusal would break the one reading that works.
DONE {.row .col} binds same-named locals
CLOSED: [2026-09-20]
A new arm at the top of dmap in lib/parse.ml, before the pair arm. Works in
let and nowhere else. The note claiming there was no grammar collision was wrong
— a bare dotted symbol in head position already parsed as something else and
failed later at the use.
DONE An empty body where a body is optional
CLOSED: [2026-09-20]
(when test), (fn []) and a defn returning () with no body all parse.
Relaxing fn opened a hole the checker had to close — an empty fn body at a
non-unit want is refused, because without that the call read a return value
nothing wrote.
CANCELLED () as a unit value in expression position
CLOSED: [2026-09-20]
() stays the type-position spelling of Unit and has no value-position meaning.
Empty forms doing the right thing covers the need that made it look attractive,
and () stays unspoken-for in case the language grows lists.
CANCELLED // for forced truncating division
/ on two integers already truncates toward zero, and casting a float division
truncates too, so (i32 (/ a b)) is the spelling. Python's // is floor
division, which is a different operation from the one that was wanted.
DONE builtin/ is a reserved qualifier
CLOSED: [2026-09-20]
builtin/length is the builtin whatever else the file has decided length means,
and it is legal whether or not anything is shadowed. It wears the package
qualifier's spelling deliberately, because builtin is reserved rather than
resolved — every other qualifier in a finished program comes from an import alias.
DONE println is variadic
CLOSED: [2026-09-20]
Clojure's semantics — every argument in order, one space between each pair,
println ends the line, (println) is the newline alone. Typed and dyn arguments
mix in one call because each gets its own printer and both share stdio's buffer.
DONE The six comparisons take two operands or more
CLOSED: [2026-09-21]
< <= > >= and = chain adjacently; != asks about every pair, Common
Lisp's /. Asking whether a sequence increases is a question about neighbours;
asking whether values are all different is a question about the set. (< x) stays
refused, and % and the shifts stay two operands.
DONE dotimes counts from where you say
CLOSED: [2026-09-21]
Three arities, with stop exclusive in every one, so (dotimes [i 0 n]) is
(dotimes [i n]) — one rule, not two. A negative step counts down; a literal step
of zero is refused at compile time and a computed zero runs no times at all, which
is terminating and deterministic.
DONE slice takes three arities, and at/slice reach a string
CLOSED: [2026-09-20]
(slice a) and (slice a n) are written out into the three-argument form. A
string slice answers a string rather than a [u8], because a byte slice is
writable-looking and these bytes are not the program's to write.
DONE One slice; as-slice is gone
CLOSED: [2026-09-21]
The input type already determines the semantics completely, so the second name
expressed no choice, and it warned at the moment nothing is wrong — the danger
arrives at the push. A Vec a call returned is accepted where an array a call
returned is refused: one dangles and one only leaks, and leaking is defined
behaviour here.
DONE The count is length, and len is a name a program can have
CLOSED: [2026-09-21]
One arm in the checker and one row in the builtin table. A call to an undefined
len is refused after every table and after the shadowing guard, so a program
with its own len never sees the refusal.
CANCELLED Counting abandonments
CLOSED: [2026-09-21]
An abandoned verb was drafted and dropped. The wait already ends when the
thunk breaks and the restart's own reply says what taking it did; a third
telling read by nobody is how a wire grows a verb whose answer drifts from what
happened.
DONE The randomness surface
CLOSED: [2026-09-21]
(rand-int), (rand), (rand-bool), (rand-int-range lo hi),
(rand-float-range lo hi). The generator is PCG-RXS-M-XS 64, so every call costs
one draw and a seeded run is reproducible — but the permutation is a bijection of
the state, so someone holding one result can predict the rest. Fine for a grid,
not for a key.
DONE A value-producing array constructor
CLOSED: [2026-09-20]
(array-fill [n ...] v) and (array-gen [n ...] f) build the whole array as a
value, so one can be a def's initialiser. Ranks are spelled flat; there is no
nested bracket syntax. Row-major order is a promise, and the fill value and the
generator are each evaluated once, before any loop runs.
DONE (array 4 T) is the zeroed array constructor
A let binding takes no type, so (let [pts [4 rl/Vector2]] ...) reads the
bracket as a two-element array literal. (zeroed [4 rl/Vector2]) was proposed
first and rejected on how it reads — unambiguous to the parser, still looks like a
two-element vector to a person.
DONE A defining form owns what happens on a re-run
CLOSED: [2026-09-20] Each computed global's initialiser guards itself with its own flag, per global rather than per startup function, and the daemon has no policy about globals at all. Dev builds only; a release build's output is byte-identical.
DONE defenum autoincrement
CLOSED: [2026-09-17] C's rule: a member with no value is the previous plus one, and the first is zero. An explicitly written duplicate is an intended alias and allowed; one produced by autoincrement walking into another member's value is refused, naming both.
DONE Every mapped raylib enum carries a Flan-side prefix
CLOSED: [2026-09-21]
An optional third column on the enum line in vendor/raylib/bindings, stripped
before the C prefix is applied. A reading choice and not a collision fix — a
keyword resolves against the expected type and against nothing else, so two enums
could always share a member spelling. What the prefix buys is the call site read
on its own.
WAIT ML-style patterns
Held 2026-09-25 as a future direction, like the JS backend: nested destructuring, guards, or-patterns, literals at any depth, exhaustiveness over the nesting.
DONE match over numbers and strings
CLOSED: [2026-09-25]
Rules out a literal the scrutinee's type cannot hold (refused, not widened as ()=
would) and a bare-name catch-all: a bare name is a nullary case.
DONE match over enums
CLOSED: [2026-09-25]
Ast.Pkw is the keyword pattern; Check.check_match resolves it against the
scrutinee's enum and lowers the match to one temporary and a chain of if ( t
:member)=, the last arm untested. Exhaustiveness is the data type's rule: refused,
not defaulted. Rules out a new IR node for it, and a keyword arm over an Option or
a data type.
DONE defdata is the tagged sum, defunion is C's untagged one
CLOSED: [2026-09-17]
The old tagged defunion is renamed defdata; defunion becomes the C-style
untagged one, serving the FFI and type punning, and is verified against the header
where one exists.
DONE A defdata carries its tag, and case order is part of the contract
Tags are declaration order from zero, so a zeroed value is the first declared
case. Option is a two-case sum wearing a special coat, so the existing arms grew
a second subject rather than a second path. A non-exhaustive match is refused,
never defaulted.
DONE An imported defdata keeps working across a package boundary
Both the tagged and the untagged form import now. It was never a blocker for
Form, because the prelude is parsed and prepended into the same flat namespace
before collection runs.
CANCELLED (Result T E) and try
CLOSED: [2026-09-25] Conditions and restarts are the error mechanism, and nothing in the game's code wants a second one.
CANCELLED (Handle T) and the pool
CLOSED: [2026-09-18]
Built, then removed. Two containers are enough — a generational slab is a library
over a Vec — and everything copies as its header once move-only is gone. The
(Ptr T)-from-resolve hazard and the pool-as-enumeration argument for managed
classes go with it.
CANCELLED drop, and unwind-protect with it
CLOSED: [2026-09-17]
drop runs code somewhere the reader is not looking, which is the C++ behaviour
the author does not want; it would not cover the motivating case, since Image
and Texture2D are raylib's types; and its one real advantage, cascading through
a container, was the case Handle made rare. with-cleanup=/=unwind-protect was
put and rejected as awkward with several resources. defer is the answer, and the
raylib begin/end pairs that seemed to motivate it are a macro problem.
DONE defer may be written in a let
A let at the top level of a function body has exactly the function's extent, so
a defer in one always registers. A loop body and a branch are still refused by
name: defer is a compile-time construct with the cleanup copied into every exit
path, so "maybe registered" is not expressible.
DONE A return runs its defers before it computes its value
CLOSED: [2026-09-25]
(return v) computes v into a slot, then runs the defers registered so far,
then returns the slot — the order falling off the end already had, and Odin's, Go's
and Zig's. One lowering in Check, so every backend has it. A value of type
Never is still returned directly, since nothing after it runs. The LLVM
emitter emits nothing after a terminator (Emit.value answers poison once the
block is closed); a bounds check in dead code used to reopen the block and
reference an operand it never wrote.
DONE edn reads into a struct and answers a dynamic value
CLOSED: [2026-09-17]
Two projects, not one, and the dynamic half goes through an allocator rather than
through a teardown operation. (edn/read bytes) answers a dyn — the separate
Value union it was first written against is gone. Rules out destructors and
finalizers: free-all takes the region.
DONE A data file's struct is derived while the program is compiled
The typed half arrived as a provider macro, (edn/defedn Name "path.edn") and
defjson beside it, rather than as the (read-edn T bytes) spelling it was asked
for. A macro may read a file at the path the call site is written at, and there is
no run-time type information to do it any other way.
DONE Assets are embedded at compile time
CLOSED: [2026-09-12]
Odin's answer. (embed "p") is a compiler feature, so it needs no build flags, no
linker arguments and no per-target packaging, and it works identically on desktop
and web. Rules out a linker-flag or per-target packaging answer, which a single
file could not have used anyway — Load gives link flags only to directory
packages.
DONE slurp and barf are the file surface
CLOSED: [2026-09-12]
(slurp path) reads a whole file into a (Vec u8); it had to wait for an
allocator, because the length is not known until the file is read.
DONE Writing a file is desktop-only and signals on web
CLOSED: [2026-09-12]
Flan has no conditional compilation, so "isolate this to desktop" is not
expressible and a build-time refusal would be unusable. barf on web signals
FileError under a restart and the program decides. Rules out both the silent
no-op — which is how a save file disappears with nothing said — and the
build-time refusal.
DONE Conditions get a parent link, not class inheritance
CLOSED: [2026-09-25] A parent has exactly Error's fields; a handler matched through the link gets a view (name, message with the values), never the child's fields. Rules out parents with fields of their own.
CANCELLED Can a condition be a class?
CLOSED: [2026-09-25] A class condition allocates on the failure path. The parent link above gives the hierarchy without it.
DONE handler-case
CLOSED: [2026-09-19]
(handler-case BODY [(T [c] ...)]) — body first, clauses after. It is a
handler-bind whose clause invokes a restart the form established around itself,
so no backend work was needed. A clause runs at the form, which is why it sees the
establishing function's locals where a handler-bind clause cannot.
DONE A restart that abandons the evaluation
CLOSED: [2026-09-21]
An expression that signalled used to offer an empty restart list, leaving abort,
which takes the compiler, the session and the game. Abandoning drops the
expression; it does not undo it, and every surface says so. At a trap there is no
transfer channel, so nothing can be abandoned, and that is correct.
WAIT find-restart and compute-restarts
Blocked on a type, not on effort: the spec gives them (Option Restart) and a
list, and there is no Restart type and no list type to return one in. The
minibuffer prompt never needed them — it reads the snapshot over the agent's
socket.
DONE into fuses at compile time because it is a macro
Not transducers and not Rust's iterators, both of which compose at runtime and need function values and allocation. A macro writes the call straight into the loop body, so the function name is syntax and never a value. What it gives up is building a transformation at runtime, which is close to useless in a game. Reductions deliberately do not share the form.
DONE loop and recur
Tail position is a permission that is withdrawn rather than a pre-pass. loop is
itself a barrier for break and continue, because a loop answers with the value
of its body so a jump out has no value to give — which is also why it takes no
label. Neither backend learned anything: a loop is a let, a While on true,
and two jumps. Mutual recursion still needs real tail calls and is out of scope.
DONE break and continue, with loop labels
Labels are Odin's, in the head position, where a keyword is unambiguous because a
loop condition is never one. It is not a goto — control can only leave a loop it
is already inside.
DONE The second tier of the standard library
The prelude had 44 allocation-free functions because there was nothing to allocate
from. append, concat, join, split, format-f64, atan2, pow and
clamp are the tier that returns new storage.
DONE The Clojure patterns deliberately not copied
Four rules. The thing being operated on comes first, everywhere, so there is no
second threading macro. A membership test says which thing it tests. A ? name
answers yes or no and a finder answers the thing or nothing. Composition reads
left to right. Rules out inheriting Clojure's contains?, some and comp
shapes.
DONE A pointer from C needs a length before it can be indexed
CLOSED: [2026-09-13]
(slice-from p n): the caller states the length and owns being right about
it. The alternative weighed — a per-binding declaration naming which argument
carries the count — cannot reach a count that is a sibling field. It owns nothing,
and free refuses it.
DONE A string cannot be returned from C
CLOSED: [2026-09-25]
A declare-c may return string: the text is copied into the context allocator
at the boundary, through bytes, and lives until that allocator's free-all. The
importer maps a returned const char * to string and still refuses a plain
char *, which the caller owns and releases through the library. TextFormat
stays unbound, being variadic. docs/BUILT.md, "A string returned from C is
copied into the context allocator".
DONE Model, Mesh and FilePathList have a defstruct
CLOSED: [2026-09-25]
Model, Mesh and Matrix are described and the generated half widened over
them; Model's material, bone and pose pointers are (Ptr u8) until Material
and BoneInfo can be, both holding a fixed array. FilePathList crosses by the
returned-string rule: dropped-files, directory-files and
directory-files-ex copy the paths and unload raylib's list before returning.
docs/BUILT.md, "Model, Mesh, Matrix and FilePathList".
WAIT A callback is the other direction of the FFI
Blocked on a program that needs one. SetTraceLogCallback and the audio stream
processors take a C function pointer, and the shim refuses a function type by
name until then.
DONE raymath is written in Flan, because static inline has no symbol
CLOSED: [2026-09-13]
Clamp, Vector2Add and the rest exist only in the header, so declare-c has
nothing to name. raymath's semantics exactly, including normalize's zero-length
guard. The C-shim alternative was rejected: it buys identical arithmetic for a
compilation unit in the build and a second place raylib's semantics are written
down.
DONE rlgl's matrix stack is bound
CLOSED: [2026-09-25]
vendor/rlgl is its own package over rlgl-5.5.h, binding the matrix stack by
hand and generating nothing else. A package and not more of vendor/raylib
because the header check is per package. core_2d_camera_mouse_zoom is ported
and builds. docs/BUILT.md, "rlgl is its own package".
TODO examples/core-input-virtual-controls.flan does not build
It defines abs-f32, which the prelude defines too, and a second definition is
refused. Nothing builds the examples wholesale, so nothing noticed.
CANCELLED cstring as a type
Odin has no string-to-cstring conversion at all; it pays the same copy the shim
already makes. The one thing it buys is the return direction, and nothing in
vendor/raylib returns a string.
CANCELLED rune as a type
Odin's is a 4-byte integer distinguished by a flag, so i32 is the same thing.
Non-ASCII text was blocked on font loading, not on the string layer, and fonts are
bound.
DONE Two function types: Fn captures, CFn cannot
CLOSED: [2026-09-21]
(Fn [T ...] R) is {code, env}, two words; (CFn [T ...] R) is the bare
address, one. Capture is by value into a stack environment, non-escaping only, and
an ordinary defn declares no environment and is byte-for-byte what it was. The
static side does not pay for the dynamic side. Rejected names: Closure, Proc, Fun,
Func, Fnptr.
DONE Escaping closures, allocated on the GC side
CLOSED: [2026-09-25]
Only a capturing fn that may outlive its frame gets a collector environment; one
only called or passed down keeps its stack environment, as every handler does.
Capture stays by value; a Map walks its values as a Vec does. Rules out a
tag bit on the environment word and a heap environment for every closure.
WAIT CFn and C's calling convention
Decided 2026-09-25: waits with C callbacks, until a program needs one.
A Flan function's signature ends with the transfer channel and a C caller knows
nothing about one, so a CFn is not a C callback today. Under a future
--no-conditions flag a CFn signature could drop the channel and reach C's
exact convention, which is the direction the author is interested in.
DONE defclass is a named dyn map with a shape tag
CLOSED: [2026-09-20]
An instance is a dyn map with its class in the object header, so get, put,
has-key? and length need no new operation. CLOS dispatch and Clojure's
arbitrary dispatch are one mechanism: a class dispatcher is the shape tag of the
first argument used as the dispatch function. Method bodies are inlined into one
dispatcher function, so a generic is one top-level name and one cell — adding a
method to a running program is an ordinary redefinition.
CANCELLED Class features deferred, each with its reason
CLOSED: [2026-09-20]
Inheritance, multi-argument dispatch, :before=/:after=/=:around= and
call-next-method, named-slot construction, compile-time unknown-slot checking,
computed dispatch values. With single dispatch on literal values there is no
specificity question, and inheritance or multiple dispatch would create one.
Compile-time unknown-slot checking needs class-typed tracking the dyn side
deliberately does not have; the runtime refuses an unknown slot instead.
DONE update-instance-for-redefined-class, the user hook
CLOSED: [2026-09-25]
Taking migrate-by-name keeps the name-matched instance, not SBCL's obsolete one,
and retries nothing; a transfer from the method to a restart below it traps.
DONE The module system stays directory-as-package
Several files in one directory are one module; a loose file is a module of one,
and no package line is required or accepted. Confirmed against Odin, which
requires the declaration despite the same rule, so the line buys only the ability
to disagree with the directory name. Acyclic imports are kept deliberately — a
definite package order is what the macro expander needs.
DONE A name imported through a package keeps the inner alias
If area/ imports shape, the type is shape/Box in the finished program and
never area/shape/Box. Forced rather than chosen: a directory reached along two
routes must arrive under one set of names or the checker sees every declaration
twice. The price is that the same directory under two aliases is refused, naming
both.
DONE A package cannot mark a name private
CLOSED: [2026-09-25]
defn- declares a function private to its module; it is a defn otherwise.
The module is the import boundary: every file of a directory package, or the
one file of a package imported by naming the file. A use from outside — a call
or the name as a value — is refused in Check, so it holds under C-c C-c too.
Code the package's own macro wrote counts as inside (SBCL's rule, not
Clojure's); what the importer wrote, passed through or from its own macro, does
not. Functions only. The edn and json internals are now defn-;
rl/get-color-raw no longer exists. docs/BUILT.md has the placement.
WAIT A private scoped to one file of a directory package
Deferred until a need appears: Odin's @(private"file")=, a function visible
to its own file only when the package is a directory. defn- covers the
package; a one-file package already gets file scope because the file is the
module.
CANCELLED A struct version word, so a redefined layout keeps working
CLOSED: [2026-09-20]
Dropped rather than deferred. A shape still being discovered lives on the dyn side
as a defclass; a typed defstruct is a commitment to a layout, and changing a
commitment restarts the process. SBCL's push-through-and-invalidate is cheap only
because its instances carry headers. docs/SBCL-REDEFINITION-NOTES.md is the
reading behind it.
NEXT An indented surface beside s-expressions
Decided 2026-09-25 as a test drive: a second reader, chosen by file extension,
producing the same forms. spec-syntax.md holds the decisions, the proposals
awaiting confirmation, and the build order. Rules out Parinfer, wisp and
sweet-expressions, and a simplified in-paren syntax — all thin the parens
without removing them.
DONE Lambdas are written with =>
CLOSED: [2026-09-26]
A block lambda inside brackets is the last thing in them, its block ending where they close: a comma
after it (so a second block lambda, or one not last) is refused, and the fix names it with let.
NEXT Indices separate like vector elements
Decided 2026-09-26: grid[r c] and grid[(r + 1) (c - 1)] read like a vector's
space-separated single values; commas always work; an index with a bare operator
needs them, grid[r + 1, c].
WAIT Calls without parentheses
Held 2026-09-26 by the author: F#-style f x y or Nim-style one-argument calls
without parentheses. Collides with space-separated vector and index elements.
TODO RET inside an open call puts the closer at the statement's column
In a .fln buffer, if and(state.paused|) then RET leaves ) at the if's column, so
the next argument cannot be typed where it belongs. A line inside open brackets,
closer-led or not, goes to the continuation column (aligned after the opening bracket).
NEXT not is a prefix word
Decided 2026-09-26: .fln writes not x (binding like F#'s not, tighter than
and~/~or, looser than comparisons); not(x) keeps working as a call.
NEXT A let takes several bindings on indented lines
Decided 2026-09-26: lines indented under a let that are name = v or name: T = v are
more bindings of the same let; anything else there stays refused. flan convert writes
consecutive lets this way.
DONE .fln has no loop or recur (decision 122)
Rules out loop~/~recur anywhere the .fln reader reads, quote included; loops are
while~/~until~/~dotimes~/~for. The Lisp syntax and its macros' expansions keep them.
DONE A .fln chain may mix < with <=, or > with >= (decision 124)
0 <= r < rows is the and of its tests; like (< a b c) every operand runs once, left to right,
with no short-circuit. Direction changes and ==~/!=~ in a mix stay refused.
TODO Hard-coded code in messages is still paren syntax in a .fln file
Types follow the code's syntax now (Types.spell). Hints written into a message's
text — (Ptr %s), (clone v), (the T x) in most of check.ml and parse.ml, the
runtime's (get 0 :body) and (/ x 0) — still print parens; each is fixed per
message as it is met.
TODO 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 write (f64 d) and
(u32 d) now. The author's file, not a lane's to edit.
DONE sand.flan's game-data.edn initialiser no longer aborts the headless import
A global initialiser that read a file at startup failed wherever the working
directory was not the author's. It is wrapped in a handler-case with a
FileError clause; away from that directory the data reads as nil.
DONE An error in a macro's body is reported where it was written
CLOSED: [2026-09-21]
A form a macro splices through keeps its own line, and the note: naming the
macro points at the call. The wire Form still carries no location: the
marshalled payload pointer is the identity key instead, which is SBCL's
*source-paths* with an address standing in for eq. A node the macro built
inherits the nearest located ancestor; only a wholly macro-built subtree falls
back to the call site. Costs about 2µs a call. Rules out putting a loc field
on the wire, and rules out structural matching of the expansion against the
arguments, which can pick the wrong one of two equal subtrees.
DONE A declared name may carry the $ sigil
CLOSED: [2026-09-25]
A name that starts with $ is refused where it is declared — every top-level
form, a struct or union field, an enum member, a data case, a class slot, and a
let, :keys, &, loop, dotimes, match, fn, handler clause, macro or
generic binding — saying $ marks a
type variable and naming the bare spelling. A defn parameter was already
refused, as a type in a name slot.
CANCELLED not= as a spelling of !=
CLOSED: [2026-09-25] One spelling for one operation; != stays, and not= is refused with a suggestion of !=.
Checker
TODO Checking a wide fold of let operands is slow
A 2000-operand (bit-and (let …) …) takes 32 s to check (37 s before the bit operators); 2000 plain names take 0.03 s. Something per operand is quadratic or worse.
DONE A dyn value takes .field and [:key]
CLOSED: [2026-09-26]
Assigning x.name or m[:k] is put: a plain map gains the key, and a class
instance refuses one its class does not declare, on read too, as get and put now do.
DONE A slice from a C pointer, and a pointer cast
CLOSED: [2026-09-25]
(slice-from p n) and ((Ptr U) p), both unchecked. Rules out pointer arithmetic
and a cast between a pointer and an integer; addr and slice-from are the routes.
CANCELLED A pointer crossing into dyn
Not needed: dyn code has no use for an address it cannot read through.
WAIT A _ body that returns an fn literal
Refused today; allowing it when the literal writes its parameter types is the proposal. Postponed 2026-09-25 while .fln takes priority.
DONE The ownership flow analysis is repealed
CLOSED: [2026-09-18]
Static use-after-move and double-free checking is gone; types, allocators and the
dev build's generation checks are the net instead. An unsound checker is worse
than none, because it is believed. Rules out a borrow checker returning as
anything but an additive pass. docs/BUILT.md carries the shape of what replaced
it.
DONE Move-only is gone with it
CLOSED: [2026-09-18]
Everything copies as its header, the copyable? predicate is off the list, and
the owning-field refusals on structs and sums are lifted. The region rule stands.
The earlier ruling that a move-only global's lifetime is the process's — so
reading one is always a borrow — has no subject left.
DONE Implicit numeric widening is legal; narrowing stays a hard error
CLOSED: [2026-09-20]
A conversion is admitted exactly when no value of the source can come out as a
different number. No second type-checking mode and no flag, which was the
objection to the -Wconversion middle ground that was asked for. Integer into
float is exact-only, containers are invariant, and dyn is not in the lattice.
DONE abs is one generic, and a bound joins to the wider type
CLOSED: [2026-09-20]
Numeric scalars bound to one type variable resolve to the join of them all, which
walks back the same day's "widening does not cross a generic binding". A joinless
pair is deferred and re-asked against the final binding, which is what makes
acceptance order-independent. integer? exists because numeric? admits floats,
where the branch spelling of abs hands back a negative zero.
DONE A conversion is legal at a bounded variable when it is legal at every type the bound admits
CLOSED: [2026-09-21]
A machine-type target needs numeric?; an enum target needs integer?;
ordered?, equal? and hashable? admit nothing. A predicate gates an operation
by what it claims, not by the set it happens to denote this week — which is why
ordered? is refused even though every type it admits today converts.
DONE The Ptr and union arms of the fill boundary are relaxable
CLOSED: [2026-09-25]
A Ptr may be byte-filled, and an untagged union is filled over its whole
size when every member may be, its members walked as a struct's fields are; a
union with a dyn member is refused naming the dyn. Everything else the rule
refused it still refuses.
DONE A compound constant expression at a bounded type variable
CLOSED: [2026-09-25]
Integer arithmetic over literals alone (Check.literal_arith) is folded to the
literal it computes wherever a type variable is wanted, so (+ x (+ 1 2)) is
admitted exactly where (+ x 3) is. A defconst's name does not fold, since it
has a type of its own. The instantiation checks the form unfolded, at its
concrete type.
DONE Generics by monomorphisation, checked abstractly, with where predicates
CLOSED: [2026-09-13]
A where clause tells the abstract pass what it may assume, so the body checks at
the definition and the call stays (sort xs). Not a type class — a predicate
carries nothing and gates a builtin the compiler already has. The fork that said
an unconstrained + over a type variable must be rejected turned out to be false,
and it is not what Odin does. The five predicates are ordered?, equal?,
hashable?, numeric? and integer?.
DONE A type variable takes a $ sigil
CLOSED: [2026-09-13]
$t binds in a parameter vector and bare t reads it. Three reasons, against
plan.org's "lowercase names are variables, capitalised are concrete": there is no
binding site without a sigil; introducing one is invisible, so a mistyped type
made a function more permissive; and [n t] gave absence opposite meanings on
the two sides of the bracket.
DONE A type variable may be written with its sigil at a use
CLOSED: [2026-09-21]
(vec-new $t), (map-new $k $v) and ($t x) work; every type position already
did. The feature was specified correctly and three membership tests asked about
the name as written, where the tables are keyed on the bare name.
DONE Milestone 5 was mostly already there
CLOSED: [2026-09-20]
What the lane added was a written integer zero standing where a numeric?-bounded
variable stands — legal because every type numeric? admits is an integer or a
float — the widening boundary, and a refusal for instantiating a type variable at
dyn, which names defgeneric=/=defmethod as the other spelling. A float literal
is still refused at a numeric? variable, since the predicate covers both halves.
DONE hashable? gates the type and not the operations
CLOSED: [2026-09-13]
put, get, has-key?, reserve, clone and map-remove are deferred to the
instantiation, joining print and println. The membership rule is not a
headcount: either the operation cannot fail after substituting, or a declared
predicate gives its failure somewhere to land. A generic that does not declare the
predicate gets no deferral.
DONE The runaway instantiation cap names the chain
CLOSED: [2026-09-21]
A structural occurs-check stops an instantiation that asks for a copy of itself,
and the refusal prints the chain of instantiations that got there rather than the
depth it gave up at. The bare depth number is a backstop that also prints the
chain. Before any of it, the compiler hung rather than failed, which wedges C-c
C-c with nothing to show.
DONE Generic types
CLOSED: [2026-09-25] A struct's parameters are its fields' $-names in first-written order, a length by position; there is no explicit parameter vector. Each application is an ordinary struct under a key, so no backend sees a parameter.
WAIT A value predicate over a length parameter
Decided 2026-09-25: waits until a program wants one.
Odin's where N > 0= is a predicate over a value, not a type, and a where
clause here admits nothing but type predicates. Whether it should take value
predicates over a length parameter deserves answering deliberately rather than
falling out of the implementation.
DONE A program is one compilation, so a generic's body is always visible
CLOSED: [2026-09-25] Odin's and Zig's model: packages are never compiled separately. The cost is build time proportional to the whole program and no binary-only packages. If separate compilation is ever wanted, Rust's answer is the one to take — a compiled package carries its generics' checked bodies and the user instantiates them.
DONE Collapsing the prelude buys 27 to 15, not 27 to 6
CLOSED: [2026-09-13]
The honest number. sum-* would widen into a type-level function, which is a
constraint system or an associated type; append-i64 and append-f64 are two
different primitives and choosing between them per instantiation is compile-time
overloading. Five do not collapse and should not.
DONE A bare {.field v} takes its type from the position it stands in
CLOSED: [2026-09-20]
The refusal moved out of the parser, where it could not see the enclosing defn's
return type, into the checker, which reads the type name off the expectation.
Refused with no want, at a dyn want and at a non-struct want. A dyn want keeps
the dyn map literal — a .field-keyed brace is not being given a second meaning.
DONE (Cell 1 2) is positional, and its arity is exact
CLOSED: [2026-09-20] Positional construction gives every field or it is refused. Not a retreat from zero-is-initialisation: a positional list cannot say which field it left out, and which field a short list omits depends on a declaration order the author is free to change. The field-reorder hazard is accepted as the price, with refactoring tooling named as the eventual answer.
DONE Shadowing a builtin is legal, and the definition wins
CLOSED: [2026-09-20] Clojure's model: allow shadowing, but warn. Builtin-wins had never been a rule — it was the implementation trying the builtin arms first. The shadow reaches exactly the file the definition was written in, decided by the file and not by the enclosing function's name, because a global initialiser has no enclosing name but does have a file.
DONE int and float are builtin aliases
CLOSED: [2026-09-20]
Exactly those two, spelled as machine types rather than prelude aliases because
the cast check does not look in the alias table and (int x) had to have a
reading. Every message still says i32. integer, long, double, uint and
str keep the teaching refusal, and any other unrecognised lowercase name is
still a type variable.
DONE A defconst is a compiler const
CLOSED: [2026-09-20]
A defconst's initialiser has to be a compile-time constant, and the refusal is
the checker's so both backends refuse the same program. Integer arithmetic is
folded before the check sees it and the folder is integers only, so (defconst
half f64 (/ 1.0 2.0)) is refused.
DONE Container globals start zeroed, and a defconst container is refused
CLOSED: [2026-09-18] A global initialiser is a compile-time constant, a container's only constant is the empty one, and a constant is not an assignable place.
DONE A numeric cast opens a dyn box
CLOSED: [2026-09-20]
Every numeric cast takes a dyn operand. The same kind unboxes; a cross kind
coerces with a once-per-site warning; a non-number traps. The cross-kind case
coerces rather than trapping, which overrides the tempting rule of matching the
parameter boundary. It is lowered as a branch into two ordinary casts so nothing
has a second opinion about range and NaN, which is what keeps the two backends in
step.
DONE nil crosses at (Option T) and nowhere else
CLOSED: [2026-09-20]
nil and None are the same value at an (Option T) boundary. At a bare T it
is a compile-time refusal where the checker can see it and a run-time trap where
it cannot. (Some nil) and (Option (Option T)) are unconstructible.
DONE Typed = and != reach strings
CLOSED: [2026-09-20] Bytewise, with length and same-pointer fast paths on both backends. Equality only — ordering a string needs a collation nobody has chosen.
DONE dyn truthiness in if, when, cond, and, or, not and while
CLOSED: [2026-09-20]
nil and false are false and everything else is true, on the dyn side only;
typed conditions stay strict bool. and and or hand back the operand that
decided them, Clojure's rule, through a desugaring that evaluates each test once.
DONE and's last operand gets a misdirected caret
CLOSED: [2026-09-25]
Already fixed by 3672da2, which blames the arm that is not a compiler temp; the
caret is on the last operand and test/test_flan.ml asserts its column. Rules
out relabelling the else arm, a bool sentinel, and inverting the condition.
DONE A typed container crosses into dyn as a view
CLOSED: [2026-09-20]
A Vec view holds the address of the Vec's own header and reads pointer and
length live, so a reallocating push cannot go stale. Rules out snapshotting a
Vec's pointer at the crossing.
DONE A view of a Vec goes stale at the push, and the warning is at the push
CLOSED: [2026-09-21]
What was built is the sentence, not a diagnostic. docs/BUILT.md beside the Vec
surface table, and spec-memory.md under Borrowing, carry it.
CANCELLED A live view at the push, detected cheaply
CLOSED: [2026-09-21]
Investigated and not built. (reserve v 100) followed by a view and a push is
correct code, so any per-push flag is a false positive by the language's own
semantics; the refined version needs liveness across control flow, which is the
flow tracking that was repealed.
DONE Catching a use-after-release statically
CLOSED: [2026-09-25] Studied (p1-p16); the compile-time checks that fit are returning, or storing into a global, an address or slice of the function's own frame. Rules out lexical tracking of arena and heap releases: the rest of the escapes the study found are the runtime epoch check's and the dev poison's.
CANCELLED A with-allocator escape check
The runtime epoch check already catches it, and a static rule flags the normal idiom of building into the caller's arena.
DONE (vec-new [u8]) is refused
CLOSED: [2026-09-25]
The type positions of vec-new and map-new take a type expression: brackets, or
a parenthesised Ptr, Option, Vec, Map, Fn or CFn. The arguments stay
ordinary expressions and the builtin reads the type back out of one
(Check.type_of_expr), so a program's own vec-new still gets values; only a
type an expression cannot hold, such as (Fn [i32] ()), is parsed as
Ast.TypeArg. Rules out a type expression anywhere else in expression position.
DONE An array literal cannot say it is [f32]
CLOSED: [2026-09-25]
(the [f32] [1 2.5]) names the element type; with nothing naming one, a literal
element takes the other elements' type. Rules out a 1.0f suffix for now.
DONE A let binding takes no type annotation
CLOSED: [2026-09-25]
(the T expr) gives any expression its want and let stays a flat list of
pairs. Rules out a type slot in let.
DONE A read-only slice type
CLOSED: [2026-09-25]
[const T] and (Ptr const T); a [T] or (Ptr T) converts at the top of a type or under another const one, never inside a writable one. The const is shallow: an element of a [const [u8]] and a Vec's buffer are writable. The address of read-only storage, a string's byte included, is a (Ptr const T), and a C const T * parameter takes one.
TODO (slice d 1) over a dyn vec traps where the typed Vec's works
A text slices to a copy, which is the typed view's meaning because a text is immutable. A vec's slice has to share the vec's elements, so it needs a view object over a dyn vec; a copy would compute something else.
TODO (slice "abc" 0 99) is not refused at compile time
A string type carries no length, so there is nothing to compare the bound against — consistent with a slice of a slice. A missed nicety rather than a hole; the runtime check still catches it.
CANCELLED An owning temporary as into's source leaks
CLOSED: [2026-09-25]
A leak is defined here, and the allocator's region reclaims the temporary the
way it reclaims every other one. Closing it needed drop, which is cancelled.
TODO Notes on the type-mismatch errors
The most common error class, and it has no second place to point at, because the function table records parameter types and a return type and no locations. A small change to what collection records.
DONE An error is a value, and there is more than one of them
The span went into the location type itself as an exclusive end defaulting to the
start, so every refusal site kept its meaning and a location nobody widened is a
zero-width span at a point. Macro provenance went the same way. Deliberately not
collecting: the reader (there is no resynchronising a paren stream), the first
checker pass, Load and the shim.
DONE An index converts from a narrower integer and never from a wider one
An i64 index is refused because 2^32+5 truncates to 5 and would read the wrong
element with no trap at all. A u32 index works; anything above 2^31 truncates to
a negative i32 and the unsigned bounds check rejects it.
DONE There is one top-level namespace
One pass rejects a second declaration of a name whatever kind either one is. The
environment's tables are per-kind, so only a function was ever checked for a
duplicate — a defn beside a defonce of the same name type checked and then
died in the backend as a redefinition of a symbol, a message with no source
location.
DONE A u64 literal is its 64-bit pattern
A negative literal fits no unsigned type, u64 included; (u64 -1) is how the
pattern is written, and a constant folds it. Rules out a negative decimal as a
u64's bit pattern.
DONE A folded constant does not skip the range check
The folding pass makes its own call to the range test, because a global's initialiser has to be a compile-time constant and only that pass knows this one is.
DONE A defn must state its return type, and an unknown one says so
(defn f [] f65 0.0) says unknown type f65 — did you mean f64?. The
unconditional return slot removed the guess, and the pre-pass that collected a
file's type names went with it.
DONE Anything that binds a name or alters control flow is recognised explicitly
The house rule that caught two misparse bugs: anything that binds a name, alters control flow, or is not yet implemented must be recognised by name and rejected if unsupported. Rules out silently falling through to a generic arm.
DONE The escape was real: a value the compiler builds trips no function-value refusal
All four function-value refusals were about surface syntax, so Allocator could
be a builtin opaque type with no user-writable constructor and the containers
needed nothing from generics. The compiler already did exactly this twice — the
lifted handler clause and the dev build's indirect call.
DONE Function values, with no capture
map, filter, reduce and a comparator-taking sort arrived with no generics
at all, which is what the diagnosis predicted. A map that changes the element
type is the one shape that did not come with them: one copy per ordered pair of
types rather than per type.
DONE CFn in a struct or a fixed array
CLOSED: [2026-09-25]
A zeroed CFn is admitted everywhere and a call through a null one signals
NullCall before its arguments run. (Fn ...) stays refused in those positions.
DONE Structural compatibility is identical layout
Same fields, same types, same order, so structural compatibility is "the same
memory" — no copy, no reordering, no adaptor. Writability is the question that
decided it: read-only structural access could gather fields into a temporary and
ignore order, writable access has to alias the real storage. Flexible field order
waits for classes deliberately, because a class owns its layout and a Vector2
should not pay for identity and metadata. Not implemented.
DONE Two refusals suggested something that does not compile
CLOSED: [2026-09-25]
vec-new and map-new with no type no longer say "or give the binding a type";
they name the type arguments alone, and (the T expr) joins them when it lands.
An unknown call whose near miss is a value — (context-allocator) against
context/allocator, or a global — says the name is a value written without
parentheses, and names no call at all when the call had arguments.
Backends
DONE The x86 backend tracks LLVM at -O0
CLOSED: [2026-09-20]
A construct LLVM compiles, this backend compiles, and the two agree on what the
program observably does. "LLVM takes this and x86 does not" is by itself a defect
report, not a discussion. The ruling was made by a typed float %, which compiled
under LLVM and died at build time on x86; the fix calls the same function LLVM's
code generator calls, so agreement is by construction.
DONE The x86 backend is the dev daemon's default
CLOSED: [2026-09-14]
flan dev picks it and --llvm leaves it; every other command is LLVM by
default. That split is what keeps the calling convention licensed — a dev build
compiled entirely by one backend, a release build entirely by the other.
--debug takes LLVM's side on its own, because an x86 redefinition module carries
no line table.
DONE A crossed reload is refused by an ABI marker
CLOSED: [2026-09-14]
Each backend defines a marker symbol its own modules reference, and the pair is
refused at dlopen naming both. Before it, an x86 host given an LLVM module died
at the first redefined function taking a struct and nothing said why.
DONE The x86 redefinition emitter
CLOSED: [2026-09-14]
The counterpart to the LLVM one, which is what the whole backend exercise was for:
until it existed the backend built whole programs and could not serve a single
C-c C-c. It emits no line table, which is why --debug goes to LLVM.
DONE Conditions, bounds checks and the cell on the x86 backend
The transfer guard, the landing pads, the per-function transfer exit, the
restart machinery and the two bounds checks are written from the specification
rather than ported, and one cell per function makes every call site redefinable.
Every program in the corpus that compiles, has a main and terminates agrees with
the LLVM build down to stderr. docs/BUILT.md, "The hand-written x86 backend, and
the four measurements behind it", is the account.
DONE Reading (uninit) before writing it is undefined behaviour
CLOSED: [2026-09-25]
Reading an (uninit) value before writing it is undefined behaviour, and the backends may differ on it. An exhausted match stays ud2 on x86.
DONE f64 to i64 out of range, and INT64_MIN / -1
CLOSED: [2026-09-14]
Carried through three backend reports as "a language decision, not backend work",
then taken: ArithError replaced SIGFPE for a divide by zero, for
INT64_MIN / -1 and for an out-of-range float-to-int cast, and both backends
agree on every case. Float division is deliberately unguarded, because IEEE
already answers it.
DONE Rt with an aggregate return was never a gap
CLOSED: [2026-09-13] Every aggregate-valued runtime result crosses through an out-pointer, so the refusal is unreachable and building a hidden-pointer convention behind it would have been wrong — that path is the C boundary, where a 16-byte slice comes back in two registers. Rules out writing a SysV classifier for it.
DONE An assignment is whole or it never happened, on x86 too
CLOSED: [2026-09-21] Two bugs. An aggregate was built in its destination, so a signal part-way through left it part-written — worst for a sum case, whose destination is zeroed first; it is built into a frame temporary and copied over now, unless lowering is bound to reach the end. Separately, the transfer exit zeroed an aggregate return value, which for an aggregate is the caller's storage; it zeroes scalars only.
DONE A global initialiser builds an aggregate through the same temporary on x86
CLOSED: [2026-09-25]
Already true when the entry was written. Every computed initialiser, release
build included, reaches emit_globals_init as Emit.startup_plan's
(set g init) and so goes through assign. The only initialisers lowered
straight into their symbol are Tast.const_init ones, which neither transfer
nor read anything. Nothing to change.
DONE A u64 converted to f64 is signed on x86
CLOSED: [2026-09-25]
--x86 converts u64 to and from f64 and f32 the way LLVM's uitofp and
fptoui do: the halve-and-double sequence one way, subtract 2^63 and set the top
bit the other, ties rounding to even. test/programs/u64-float.flan runs on both
backends. A cast that misses u64's range reports it as [0 18446744073709551615].
DONE An aggregate built in place never reads its own destination
CLOSED: [2026-09-25]
assign builds in place only when the right-hand side settles and reads no
storage the destination lies in; otherwise it goes through the temporary. Two
predicates, not one: settles is about leaving part-way, reads about a value
reading itself, and a read through a pointer counts as reading everything.
test/programs/self-read.flan runs on both backends.
DONE The aggregate temporary is an unrooted buffer while it is filled
CLOSED: [2026-09-25] The buffer stays unrooted. Every dyn word written into it while a sibling field may allocate is also in a root slot of its own, pinned as that field was computed, so the collector sees what has been built so far without a root for the buffer.
TODO Marking through a descriptor an x86 reload module emitted
The module links and runs. What is not proved is a collection running while a live instance of a dyn-holding struct sits in a frame of a body that module delivered. The same holds for a closure environment a reload module allocated: the module builds on both backends, and nothing yet collects while one is live. For the next sweep rather than for a lane.
DONE A string literal crosses to C uncopied
CLOSED: [2026-09-25] Both backends write a NUL after every literal and a declare-c passes a literal argument uncopied. Rules out a NUL guarantee on any other string: a slice is pointer and length.
DONE Frame descriptions are gated on –debug
CLOSED: [2026-09-25]
The x86 backend emits its .cfi directives in every build, redefinition modules
included, so an unwinder never has to guess at a Flan frame. Cost: about 40 bytes
of .eh_frame and .eh_frame_hdr per function, 2 KB on json.flan's 210 KB
binary.
WAIT A !DILexicalBlock per Let
Decided 2026-09-25: waits until Flan is debugged in gdb or lldb; the break buffer, which reads the shadow stack, already answers correctly. Inside nested =let=s that bind the same name, a debugger still answers with the outer one. The disambiguating suffix makes both visible, which is not the same as making the answer right. It needs block structure the typed IR does not carry, and the variable declarations moved out of the entry block.
CANCELLED UBSan sees no Flan code, and no flag changes that
CLOSED: [2026-09-25]
The language defines the cases UBSan would catch itself: a computed shift count is masked, and a float-to-int cast out of range or of a NaN signals ArithError. Flan code produces no misaligned access.
DONE A JS backend is a dialect, not a second machine
CLOSED: [2026-09-17] Object mapping, not linear memory: a Flan struct becomes a plain JS object. That means garbage collection, so no pointers, no manual free, no arena and no allocator — some Flan programs will not compile to JS, and that is named up front rather than discovered. Reader conditionals are Clojure's inline ones, not file-level target naming. Rules out asm.js-style linear memory, which is what wasm exists to replace.
WAIT The JS backend is held behind the dev loop
Held 2026-09-17. wasm32 already reaches the browser, and a third backend beside the two that exist
is the largest item on the list. In the meantime lib/js.ml compares string views
by identity and answers string equality wrongly; the option on the table when it
is picked up is a loud refusal in that arm rather than an implementation, so the
dialect says it cannot do this instead of saying something false.
TODO The web target does not reach four things
Nothing has been opened in a browser — node sand.js gets as far as glfwInit
before dying on window is not defined, which proves the module is live and
proves nothing about the canvas. Asyncify's cost is quoted rather than measured,
no frame time on web has been taken, raylib's audio and threads on web are
untried, and a wasi build reaching raylib fails on undefined symbols because the
raylib link line is tagged native.
DONE A package's .c files can be addressed to a target
A tag in the name before the extension — flan_agent.web.c replaces
flan_agent.c on a web build. Refusing the agent package on web was the
honest-looking option and is ruled out by arithmetic: there is no conditional
compilation, the flagship program calls into the agent unconditionally, and
Reach cannot prune a package something reachable calls into. A refusal is only
honest when the caller has a way to not ask.
DONE The IR and the disassembly are not annotated with the source
CLOSED: [2026-09-25]
Every Flan form heads the code it produced: a comment in the .ll (Emit.annot),
and in the x86 listing a per-function map from byte offsets to forms, written as
comments after .size (X86.srcmap). flan emit and every dev build annotate;
--no-annotate turns it off, and the objects are identical either way (tested).
The daemon's C-c C-a places the forms from what it kept of each build: the x86
map, or on LLVM the line table read with objdump -l plus the .ll's headings,
which exists only under --debug; an -O2 LLVM session says so in :note. The
lowering buffer annotates all four sections, the two llc ones from a --debug
copy of the IR. Rules out writing a disassembler, and reading the source off disk
at disassembly time.
DONE A temporary allocator, wiped each frame
CLOSED: [2026-09-25]
i64->bytes and f64->bytes allocate from context/temp, which grows rather than failing. A
dev build wipes it at a top-level agent poll; an expression run at a stop gets a scratch temp arena.
Runtime
TODO Heap quarantine in dev
A dev build could hold freed heap blocks back from reuse for a while (Zig's debug allocator), so a read after a free sees poison rather than a new owner's data.
DONE An index out of range is a condition
CLOSED: [2026-09-13]
A failed bounds check signals BoundsError; a handler can answer it, a
restart-case catches the transfer, and an unanswered one dies with the location
and the index. Vec's checks are plumbed the same way, since indexing an array
and indexing a Vec are one form. Rules out exiting on the spot.
DONE No restart is established at the failing index
CLOSED: [2026-09-13]
retry exists for allocation and for files because those attempts are repeatable;
nothing a handler can do makes index 51 valid for a length-50 array. use-value
for the index would cost every indexing operation a restart frame. What answers a
bad index is the program's own continue.
DONE ArithError signals and dies, with no restart offered
The runtime cannot push a restart frame on a program's behalf — a restart frame is
allocated in the restart-case that offers it. Rules out use-value at the
failing operation. Only a saturate restart on the cast arm alone was left as a
question, and it is not written anywhere.
DONE slice-from's runtime refusal names the promise
CLOSED: [2026-09-14]
It used to reuse the slice error and report a range and a length the caller
never wrote. slice-from is the one form where the compiler cannot check
the thing that matters, so its refusal is where the promise is spelled out. The
check is signed on purpose: a negative length sign-extended is a huge unsigned
value an unsigned compare waves through.
DONE A dying program uses _exit
exit runs the atexit chain and the ELF destructors, which want the loader
lock the agent's listener thread may hold inside dlopen, so a program that
should die could hang. Unconditionally rather than only under --dev.
DONE A Map's bounds check and the stale-container failure still die
CLOSED: [2026-09-25]
A Map has no bounds check: get and map-remove answer None for an absent key
and nothing on the map path indexes by a number, so there was nothing to convert.
The stale-container failure keeps dying — the region was released and there is no
frame to go back to that would not read freed memory. Rules out a signalled
condition on the stale path.
DONE Six trap paths park instead of killing the session
CLOSED: [2026-09-18] A trap in a dev session stops for inspection rather than taking the daemon with it. Two of the six refuse the resume, because there is nothing to resume into. A standalone build dies as before.
DONE bytes copies, string constants trap, and a segfault parks
CLOSED: [2026-09-20]
An in-place sort over (bytes "INSERTIONSORT") wrote into a string constant and
took the whole session down. (bytes s) allocates a writable copy through the
allocator surface — never a hidden malloc — and (bytes-view s) is the old free
reinterpret. A dev build installs a SIGSEGV handler that parks in the break loop;
it needs SA_NODEFER, because the handler is the park and never returns.
DONE Allocators, (Vec T) and StorageExhausted
Three amendments to a frozen spec: free-all is retain-capacity with
arena-destroy beside it; the allocator context is a dynamic variable rather than
a calling-convention parameter; and the Vec header is the same size in every
build, because a layout that changes with a build flag can disagree silently
across the reload boundary. One addition: a budget, because retry needs a
handler that can make the same request succeed.
DONE A stale slice reads poison in a dev build
CLOSED: [2026-09-25] A dev build fills a Vec's old buffer with 0xDEADBEEF when a push moves it; nothing traps. Rules out a dev-only word on every slice, which would change the slice layout per build.
DONE The allocator's budget is not in the spec
CLOSED: [2026-09-25]
spec-memory.md has a Budget subsection under Allocators, as built: a ceiling on
live bytes, 0 for none, that a retry handler raises. The failure bullet says
"raises the allocator's budget" where it said "grows the arena", since no arena
grows. A growable arena is not ruled out; nothing here asks for one.
DONE The Vec header is not the size the spec fixes
CLOSED: [2026-09-25] Five words in every build, the epoch included, so a release build still traps on a container whose region was released. spec-memory.md, "Every build detects a released region", now says so. Rules out a four-word release layout.
DONE arena-destroy under a live view reads freed memory
CLOSED: [2026-09-25]
No ordering of the frees fixes it: the container holds a pointer to the header.
arena-destroy now frees the pages and the arena record and retires the
allocator header — epoch bumped, procedure trapping as DestroyedAllocator,
never freed — so the stale check reads live memory on every side that makes it.
The next arena-new takes a retired header back, epoch kept, so a loop of them
stays flat and a container made before the destroy still traps.
Rules out freeing the header while any container may hold it. See
docs/BUILT.md, "Three amendments to a frozen spec".
DONE Map removal costs a backward-shift loop
Removal landed, with the loop the spec predicted as its cost. Deferring it was what had kept the implementation free of tombstones and of Odin's backward-shift loop; taking it is taking the loop. Superseded by the Swiss table, which removes by tombstone and moves nothing.
DONE The Map is slower than CPython's dict at a million entries
CLOSED: [2026-09-25] The Map is a Swiss table: one control byte a slot, key and value side by side, groups of eight probed as one 64-bit word, seven-eighths load, removal by tombstone with a same-capacity rebuild to sweep them. Header, entry points and iteration contract unchanged. Rules out Robin Hood, the backward shift and separate key and value runs; SSE2 groups are not built. See docs/BUILT.md, "The Map is a Swiss table".
DONE dyn maps and interned keywords
CLOSED: [2026-09-20] Keywords are interned and immortal, so equality is pointer equality and there is no collector object to attribute to one.
DONE Per-type descriptors make a dyn field in a struct markable
CLOSED: [2026-09-20]
A struct or a condition holding a dyn carries a descriptor naming the byte
offsets the collector must follow, emitted by both backends and by both
redefinition emitters. The stopgap refusal on a dyn field in a struct is lifted.
DONE A dyn read out of a place is rooted while a sibling operand runs
CLOSED: [2026-09-25]
An operand holding a dyn word — a call's, runtime call's or primitive's
argument, a struct or array literal's element, the temporary array an at or
slice indexes — is spilled into a pushed root slot as soon as it is computed,
whenever another operand in the same list does not settle. Same
explicit-slot root stack on both backends and on wasm32; rules out anything that
scans the native stack or registers. See docs/BUILT.md, "Operands held beside a
sibling".
DONE A redefined defclass migrates its instances lazily
CLOSED: [2026-09-20] CLHS 4.3.6. Nothing is enumerated and no heap is walked — the redefinition is constant time and each instance pays once, at its next touch. Neither printer migrates, so a stale instance shows its old slots to the editor until something touches it. An instance holds only declared slots — get, put and set refuse any other key — so the migration's drop loses nothing a program wrote.
WAIT A class registry keeps one slot list per class, not one per layout version
Decided 2026-09-25: waits for a case name-matching migration to the current list gets wrong. "A redefined class's old instances stay resolvable" needs every version's metadata retained for as long as any instance holds it, the way nothing is ever =dlclose=d. What exists is one current slot list and one generation per class, and migration is lazy and additive.
DONE A spin is not patience: the registry's slot read
CLOSED: [2026-09-21] Sixty-four bare re-reads of one word finish in about two microseconds, so against a descheduled writer the old budget was not small — it was zero wall clock. There was no timeout to widen; what was added is the first wall-clock patience the slot read ever had. A refusal now means the table genuinely would not hold still.
DONE The 4K result cap is not a transport buffer and stays
The bound is the buffer the game thread writes into, so a growable one means the frame thread calling realloc, and that breaks the seqlock, which assumes the address it copies from does not move. Removing it is a redesign of the read and belongs with moving the read to a frame boundary. Rules out deleting it as transport machinery.
DONE The snapshot copying and generation stamping stay
It was never about two address spaces — it is about two threads, and there are still two. The break loop polls, a thunk it runs is arbitrary Flan that pushes and pops the live restart list, and the generation stamp keeps a nested break from claiming a choice made against the outer one. Rules out deleting them with the transport.
DONE The seqlock's losing race has no test
CLOSED: [2026-09-25]
flan_dev_result_read_hook runs between the copy and the second counter read,
and dev_limits.c's race mode writes from inside that window: once (the read
retries and returns the new value), every attempt (it gives up with nothing), and
a write left open (it never copies). A hook rather than a second thread, so the
interleaving is the same on every run; rules out a timing-based stress test here.
DONE The snapshot generation's racing stale claim has no test
CLOSED: [2026-09-25]
flan_agent_break_poll_hook runs on the stopped thread where a thunk from the
poll would, and test/agent_hooks.c uses it to choose at an outer break and then
nest a break on top before the outer one looks. The inner break turns past the
choice and resumes only on its own. Rules out a sleep-timed socket test for this.
DONE A choice made at an outer break survives a nested one
CLOSED: [2026-09-25] A nested break keeps the choice pending for the break below it and puts it back when it is left, so the outer break takes it without being asked again; and a restart is taken only after every job queued before it was accepted has run. Rules out a scripted fix-then-retry running the old body.
DONE SNAP_MAX and SNAP_NAMES are read rather than tested
CLOSED: [2026-09-25]
test/agent_hooks.c drives both through programs/agent-hooks.flan, which recurses
with one restart per level: 71 restarts list 64, and 31 with 200-byte names list
20, each whole, with the terminal counting the rest and a take by index landing
in the frame it names. The slot kept back for abandon-evaluation under
truncation is still not driven: it needs a thunk in progress.
CANCELLED Probing for an interior overrun under memcheck
CLOSED: [2026-09-12] An arena is one malloc and a Map's four runs are one allocation, so an interior overrun is not observable, not merely unreported. No client request fixes it. Recorded so it is not re-proposed as a gap in the sweep.
DONE ASan does not see an uninitialised read; valgrind does
CLOSED: [2026-09-12] memcheck needs no instrumentation — it works on the binary, so hand-written IR arrives on the same footing as clang's C, which is why it was reachable where MSan was not. Rules out treating a clean sanitizer run as evidence about stack lifetime.
DONE The memcheck half of the allocation registry
CLOSED: [2026-09-14] The registry knows a free-all killed everything in a region, so a later read through a pointer into it is answerable; memcheck is told the same fact, so the same read is reported. The two stay two claims — different tools reaching different people.
WAIT The leak question across the corpus
Decided 2026-09-25: one pass over the whole corpus with LeakSanitizer and memcheck's leak check on. Memory an allocator holds by design is set aside; memory nothing owns is a leak and is fixed. The sweeps' default stays leak-checking off.
WAIT on the between-batches sweep slot: the switches are
ASAN_OPTIONS=detect_leaks=1 dune build @sanitize and FLAN_LEAKS=1 dune build @valgrind.
A 22-program LSan sample found no runtime leak; program leaks in map-remove and
map-keys are fixed. Needs a decision: (bytes s) and (clone slice) with no
allocator answer a [T] over a heap block nothing can free (bytes-copy.flan).
Temp-allocator by default, as i64->bytes is, or a (Vec T) the caller frees?
DONE trap_oom has no site
CLOSED: [2026-09-25]
flan_dyn_at, flan_dyn_set_at and flan_dyn_push take the call's site as
ptr+len, like the arithmetic, and every trap they reach prints it — type, range,
a view's tag check, and push's growth failing. trap_oom takes a site and only
push gives one: its other callers are the collector's own allocations, which
have no line to name. A stale view's check prints the site when at or
set-at reaches it; reached from length, printing or equality, it has none.
DONE A formatted number outlives its frame
CLOSED: [2026-09-25]
i64->bytes and f64->bytes copy their text into the temp allocator; the prelude and
the printer keep the frame slot. Rules out refusing the escape, which needs flow tracking.
DONE A shift count is bounded two different ways
A literal count out of range is rejected by the checker; a computed one is masked
to the operand's width minus one. A shift by the operand's own width is poison in
LLVM rather than a wrong number — (<< 1 32) compiled to a bare return. The mask
is what the hardware does anyway and is folded away when the count is constant.
DONE The linked-list frame beat an array with a stack pointer
The opposite of what the escaping-alloca argument predicts, and the measurement
that first said otherwise was comparing a 40-frame binary with a 600-frame one.
That is why every number in docs/BUILT.md is a minimum of nine runs.
DONE runtime/flan_dyn_stub.c is dead
CLOSED: [2026-09-25] Deleted, in a sweep for dead code across the repository in which each removal was first shown unused. flan_dyn.c is the one implementation of the flan_dyn.h ABI; a stand-in beside it is not to come back.
DONE A destroyed arena always traps, even after its record is reused
CLOSED: [2026-09-25] An Allocator value is the record and the incarnation it was made for, compared on every use. Rules out static tracking of destroy, which is move semantics. docs/BUILT.md has the cost.
DONE A mixed array literal with no want is a dyn vector
CLOSED: [2026-09-25] Elements that agree, numbers meeting at the wider, are typed; elements that mix are a dyn vector, except numbers with no common type, which are refused. Rules out the first element typing the rest.
Dev loop
WAIT A _ caller whose type follows a redefined callee
Its signature changes in the session but its body is not recompiled, so every call stops on StaleCall naming a type nobody wrote. Proposal: recompile such callers. Postponed 2026-09-25 while .fln takes priority.
DONE The dev loop, step 1: the reload primitive
A list of top-level forms is recompiled and installed into a running process, and call sites compiled before those forms existed follow them through an indirection cell.
DONE The dev loop, step 2: a name the process was never built with
A defn or a defonce the process was not built with can be added and then
redefined again, through a by-name cell in the dev registry.
DONE The dev loop, step 3: the agent installs at a frame boundary
The program takes a redefinition over the agent socket and installs it between frames.
DONE The compiler is a thread inside the program
CLOSED: [2026-09-14]
One binary that is the compiled program and the whole OCaml compiler. The program
keeps main() and the compiler comes up on a side thread beside the agent's
listener, rather than the program being loaded into the daemon — macOS needs a
window on the main thread, and the agent was already a server inside the program.
Crash isolation is given up knowingly. docs/BUILT.md, "One process", is the
account, and the embedding spike that made it safe to commit to is under it.
DONE Transport and code generation were independent, and transport was the larger prize
CLOSED: [2026-09-14] Merging the processes removed the transport; a new backend removes code generation. Neither implied the other, and the two were repeatedly conflated. The order held: spike the embedding, merge while keeping the existing build path exactly as it was, measure what was left, and only then choose a backend.
DONE Re-runnable main after the window closes
CLOSED: [2026-09-17]
A finished program parks instead of dying, and a daemon op wakes it and re-enters
main on the same thread, because a window belongs to the thread that opened it.
Globals are not reset between runs — the process never died. Rules out a fresh
process per run.
DONE An accepted re-run reads as running
CLOSED: [2026-09-21] A caller that asked for a re-run and then waited for the program to park was answered by the park it had just ended. The state is set under the lock that accepted the request, so the state is the decision rather than a report of it. Rules out teaching the tests to wait on something else — the runtime refuses a second re-run precisely because the first is committed, so the two answers disagreed about the same fact.
DONE C-x C-e installs a top-level form
CLOSED: [2026-09-17]
Context-aware: a top-level form compiles and installs, anything else evaluates as
an expression. C-c C-c stays the explicit alias.
DONE C-x C-e answers against a parked program
CLOSED: [2026-09-17] An expression evaluates against a parked program by draining the agent's ring from the park, rather than by loosening what a frame boundary is. Safe because a parked program has no concurrency at all.
DONE Evaluating a def assigns
CLOSED: [2026-09-21]
def is Common Lisp's defparameter, and evaluating a defparameter assigns.
The previous reading — a promise about the next re-run — was wrong for the reason
the form is named after. Both events happen: an evaluation that assigns and a load
that re-initialises. defonce and defconst are unchanged.
DONE An evaluated expression that signals says so at once
CLOSED: [2026-09-20] The wait recognised only a pause, so every other stop fell through to a timeout arm whose sentences were about something else. "A stop entered after the module was delivered" is a generation number rather than a name, so evaluating from inside a break into a thunk that stops on the same condition class is settled by comparing two integers.
DONE The first evaluation no longer stalls behind the agent socket
The accept loop used to sit behind a ten-second wait for the agent socket, so a program that binds its socket late — or not at all — looked ready and answered nothing. The wait is no longer in front of the accept loop.
DONE The parked-program note is said once per park
A finished program is always parked, so re-evaluating main after every run
printed the whole note again. The daemon remembers whether it has said it for this
park.
DONE A session ends when no editor has held the socket for a grace period
CLOSED: [2026-09-18] Two graces — five minutes parked, thirty seconds live — because a parked program is invisible and a live one may be a window somebody is watching. Armed only after a first client has connected, so a headless daemon is untouched.
DONE Parked orphans exit with their daemon
CLOSED: [2026-09-18] A child outliving its daemon is killed by the kernel. Found in passing that half the observed orphans were merged daemons whose editor had vanished, which the grace above answers.
DONE The daemon survives a client that closes mid-reply
CLOSED: [2026-09-14] A reply written into a socket whose reader had gone used to kill the process, and in a merged build that process is the program, the compiler and the listener at once. It reported as a connection refusal on a path that plainly existed, which misdirected two investigations.
DONE The daemon leaves its temp directory behind
CLOSED: [2026-09-25]
A session that ends cleanly — close, or the editor gone past the grace —
removes flan-dev-<pid> (program, modules, agent socket) and its own
Build.workdir. Kept on a crash: the accept loop raising, or a two-process
child killed by a signal. Only the two paths named for this pid are touched;
nothing sweeps other sessions' directories.
DONE (agent/start) takes no argument, and binds before main
CLOSED: [2026-09-20]
The zero-argument form takes the daemon's socket where there is one and an
announced path where there is not, bound by a constructor before main. Reach
prunes a package nothing calls into in a release build. flan dev links the
agent's C into every program it builds whether or not the source imports it; a
release build links it only when the program calls into it.
DONE The daemon's "has not called (agent/start …)" note is unreachable
CLOSED: [2026-09-25]
Retired, with the matching arm of an evaluation's timeout, because it named the
wrong cause: with the agent linked, its constructor binds the socket before
main, and the one way left to be unbound is a socket path over 107 bytes, which
flan dev refuses at start, naming TMPDIR. A program with no agent linked is
told it has none and how to add one. No reply says (agent/start ...) has not
been called.
DONE The allocation registry
CLOSED: [2026-09-13] One insert per allocation, always on in a dev build, no opt-out. Nothing per-region, no range recording, no per-allocator opt-out. It does not cover stack locals and globals, which the shadow stack and the static type table answer by name; a stack address is deliberately not in the table.
DONE The shadow stack
CLOSED: [2026-09-12]
The route to a backtrace and to locals together, dev-only so a shipped game pays
nothing. Chosen over DWARF deliberately: DWARF owes a lexical block per let
before shadowed locals are even honest, and that buys locals in lldb rather than in
the break loop.
DONE The x86 backend pushes shadow-stack frames
It does, in every dev build. The daemon no longer rewrites the agent's reply to say otherwise.
DONE A restart is not a transaction
If a frame mutates a global and then signals, taking a retry re-runs the
mutation. Nothing rolls back, and Common Lisp offers no help either. The discipline
is that the author chooses where the retry boundary is, and it matters more here
because the intended use is a game loop.
DONE The break loop's display pass
CLOSED: [2026-09-20] Off a dogfooding session that hit a bounds error with no line number and unreadable field names. The headline reads the condition's fields inline, the buffer draws the source line with a caret, compiler temps are hidden from the locals listing, and a shadowed restart is takeable by index.
DONE The break buffer opens by itself when the program stops
The client already knew the moment it happened. Three questions settled with it:
it displays rather than takes focus, (pause) is not special, and the behaviour is
a defcustom.
DONE The condition itself is on the wire
The agent keeps the condition pointer beside its name and a verb hands it back, so the editor can render the condition's own fields rather than only its class.
DONE Reading a value compiles nothing
CLOSED: [2026-09-25]
locals, inspect, at, condition and globals read memory through the agent and walk it in lib/inspect.ml; only writes still build a module. Rules out a render thunk per read, and a reply spelling a value differently from C-x C-e (test_dev's parity block). docs/BUILT.md, "Reading a value compiles nothing".
TODO The inspector holds a value
Reading needs no module now, so an address and a type are enough to keep a value on the daemon's side between requests, the way CIDER keeps a JVM object. Nothing holds one yet: the Emacs stack is still a stack of expressions.
DONE The watch table stays pushed
CLOSED: [2026-09-25] The watch table stays pushed, and shares the push channel program output moves to.
DONE A watch over a struct or a slice
CLOSED: [2026-09-25]
(watch "name" v) is a checker arm beside print, sharing its render context
with the emitter aimed at the watch slot, so any value watches as it prints. The
value is evaluated once, before the table is asked whether it is armed, so the
program behaves the same with or without a watch buffer open. Outside a dev
build, and always in the JS dialect, the backend drops everything but the
value's evaluation, so a release build makes no call. The declare-c scalar
entry points stay. See docs/BUILT.md, "The scalar entry points, and the
form for everything else".
DONE Two ways to root a walk
The inspector takes a frame and a slot index as well as an expression. An index is the thing a listing can hand back where an address is not something an editor should hold. The one prediction that did not survive contact: walking between the two modes was listed as a cost and is not one, because a stack entry carries its own root.
DONE C-c C-c on a generic installs its instantiations
Only instantiations reach the function list, so a generic name used to report that it had installed nothing at all. The session expands to instantiations before reporting.
DONE Signature generations and stale-caller warnings
CLOSED: [2026-09-25]
A changed signature installs. A dev cell is three words (body, signature word,
signature text), and every call through it, and every function value taken from
it, compares the word against the one the site was compiled for; a mismatch
signals StaleCall and the call is not made. The reply's :stale lists every
compiled caller by file:line, and recompiling one clears it. The word is a hash
of the signature, so changing it back makes old callers current again. Rules
out versioned bodies and trampolines, and redirecting a value taken before the
change. main stays refused: its caller is startup code no cell reaches.
docs/BUILT.md, "A signature change installs".
DONE An expression's module is unloaded unless it hands out a constant
CLOSED: [2026-09-25] A thunk's string literal is a copy the process keeps, and registry names and initial images are copied by the runtime, so none of them pins the module; a condition's name or a restart's text still does. Rules out unloading on a guess about a literal.
DONE A redefinition delivered while parked installs on the next re-run
The park used to drain the agent ring only when something had asked it to poll, and a plain redefine does not, so every generation ran one re-run later than whoever pressed the key expected. The drain happens in front of the exit now.
DONE A dyn value from eval-expr never reaches the reply's value field
CLOSED: [2026-09-25]
The renderer's emitter has a dyn entry: println keeps flan_dyn_print to
stdout, and the REPL's renders through flan_dyn_emit_dev into the value
buffer, so a dyn answer is the reply's :value on both backends. A dyn text is
quoted there as a typed string is. Rules out a dyn value arriving on :output.
DONE Memory diagnostics on demand
CLOSED: [2026-09-20]
Two classes of allocation, surfaced on demand and never changing what compiles.
Precision over completeness: a site that does not allocate is never marked, and
vec-new, map-new, dyn arithmetic, keywords and dyn push and put are
deliberately silent, each for its own reason.
DONE A debug tracking allocator over the raylib boundary
CLOSED: [2026-09-25]
A dev build counts calls to bindings named Unload* against the bindings that
return the same struct type (not Get*, except GetClipboardImage), noting
the release and acquisition in the generated wrapper and the site at the call.
A resource is keyed by its first pointer field, or its id, so writing other
fields keeps the match. The report at exit is behind FLAN_DEV_LEAKS. Release
builds emit what they did before. Rules out keying on the whole value and
tracking bare pointers. docs/BUILT.md, "A dev build counts a library's
resources".
DONE –dev –sanitize was unbuildable, and nothing built it
CLOSED: [2026-09-21] clang's sanitizer pass faulted on a constructor table naming a function the module only declares; the table names a local definition that calls the two now. A configuration nothing builds can be broken for a month, and this one was, so the sweep builds dev programs twice under the existing alias.
DONE test_dev's abort rows raced and killed the binary
CLOSED: [2026-09-20] What says an abort worked is the waitpid underneath it, not the reply — the program exits from its own thread while the reply is composed on another, and nothing orders the two. The read raised on a closed socket and the test binary exited 1 with no failure line, which is the worst shape a failure can have when a lane is judged on the exit status.
DONE A transient signal 11 on a globals daemon
CLOSED: [2026-09-25]
Not a segfault. The report was OCaml's signal number, and in OCaml's numbering
-11 is SIGTERM (SIGSEGV is -10). Nothing in the daemon sends itself SIGTERM, so
it was killed from outside. The test binaries now print a signal by name
(Test_support.signal_name); a number from WSIGNALED is never printed raw.
DONE test_dev daemons fail to bind under load
CLOSED: [2026-09-25]
The failure was a full /tmp, not the load. /tmp is a tmpfs, and every session
left its flan-dev-<pid> build directory behind (a test_dev run leaves about
300MB); a few concurrent runs filled it and each daemon after that died on its
link with ENOSPC. The fix is the session removing its own directory on a clean
close, which the build-plumbing lane owns. The half-write test's abort also
goes through aborted now, which took the same run down with an uncaught
Wire.Closed.
DONE A test binary that hangs is killed by its own alarm
A reader branch that forgets to advance loops for ever and the suite waits as long as it is left to; in CI that is a job the runner kills with nothing named. The alarm is generous on purpose, because an alarm that fires on a slow machine is a flake and a flake is how a watchdog gets deleted.
DONE A test binary leaves nothing in the temporary directory it was given
CLOSED: [2026-09-25]
Every test binary makes a fresh flan-t<pid>-<hex> under its TMPDIR, never
adopting an existing entry, exports it as
TMPDIR to everything it starts, and removes it on exit, on the watchdog, and
on SIGINT or SIGTERM (test/own_tmp.ml). A binary exits nonzero if that
directory survives or if a flan-<pid> or flan-dev-<pid> for its own pid
appears beside it. dune test was already clean, since dune gives every action
a private TMPDIR; the directories in /tmp came from binaries run directly or
through dune exec. A build's empty flan-<pid> goes at process exit. Rules out
a sweep that deletes directories by pid liveness, and any change to a killed or
crashed session keeping its directory.
DONE A forked acceptance failure reaches the exit status
The fork pool is drained before anything reads the failure count, and a nonzero count is an exit status. A red row used to be able to print and pass.
WAIT An x86 dev session's read of a dyn global after an allocating thunk failed once
WAIT on a recurrence; the test now prints the failing read's own reply. The one failure's message came from a second read, which said "kept"; the failing reply itself was not recorded. Not reproduced in 350 churn-and-read cycles under 8-way load, three concurrent test_dev runs, or a valgrind run of the cycle, which was clean.
Editor
TODO C-c C-l on a generic needs a session to find its copies
The lowering view compiles the file, but only the daemon's defs says which
functions are a generic's copies, so with no session a generic's name shows
nothing. Needs a way to ask the compiler for a file's copies of a name.
DONE The syntax table and the font-lock lists are read off the parser
CLOSED: [2026-09-21]
The special-form list is the heads the parser dispatches on, the builtin list is
the checker's, and the constants are the bare symbols; defmacro is a definer.
Three kinds of name had been mixed into one list, so push was drawn like let.
DONE A user-defined macro is highlighted from the live session
CLOSED: [2026-09-21]
The definitions op gained a macro kind and the mode consumes the live cache as a
font-lock source through a matcher function over a hash table. Macro-ness had been
erased by check time, so the op was extended rather than the editor made to guess.
CIDER over nREPL is the precedent. The rules carry no override flag, so the static
table wins by mechanism and a program defining its own length cannot repaint the
builtin.
DONE A discarded form is drawn as a comment
A syntax-propertize function gives a #_ span the comment class, clojure-mode's
approach, including chained discards. The compiler side was never the gap.
DONE A binding vector indents name-under-name
CLOSED: [2026-09-21]
The indenter is ported from clojure-mode's source rather than derived from it, so
brackets-mean-binding and pairs-align come with it. defn parameter lists and
restart-case clause parameters were the same shape and were fixed in the same
pass. Deriving from clojure-mode at runtime stays rejected: it would add an
external dependency to a mode that ships in this repository and needs nothing
beyond stock Emacs, and that community is mid-transition to a tree-sitter mode.
DONE Every tracked .flan file reindents to itself
CLOSED: [2026-09-25]
clojure-mode decided each shape. The indenter was wrong on one: a with- head,
and a qualified def… or with- head, now indents as a body, and a qualified
name finds its unqualified part's spec. The rest was hand formatting and was
reindented: a cond or match result on its own line sits under its test, and
an ordinary call's later arguments align under its first. A lone ; comment
line goes to comment-column in every Lisp mode, so continuation comments are
written as ;; lines above the code instead.
DONE C-c C-i inspects the expression at point
No prompt, because the expression is already written in the buffer. C-u opens
the minibuffer instead, pre-filled, and so does having nothing at point to take.
DONE An evaluation's value is shown at the end of the line and echoed
Both, not one or the other. The overlay used to suppress the echo on the argument that saying one number twice teaches a reader to skip both; in practice the echo is the half still there after the next keystroke takes the overlay down.
DONE Two streams and one tool list
CLOSED: [2026-09-20]
The daemon's log is one buffer under compilation-minor-mode, the REPL is the
working stream, and a third buffer holds the errors and the memory sites. The
separate output buffer is gone — the program's output already rides every reply,
so both destinations were editor-side routing all along.
DONE A breakpoint is marked from the editor, without editing the buffer
C-u before an evaluation marks a form so the program stops when it runs, for
three targets: the top-level form, the last expression, and the form point is
inside. The mark is a separate field the daemon splices after parsing, where
locations are already attached, rather than text spliced into the source — which
would shift every line and column after it. It sticks, like Clojure's, until the
form is evaluated again plainly.
DONE A backtrace and a frame's locals have an editor surface
Both were daemon ops with nothing calling them. One command shows the backtrace with the selected frame's locals.
DONE Hex, binary and an address on a primitive in the inspector
CLOSED: [2026-09-25]
Hex and binary were already drawn under every integer. The slot root's reply
now carries :addr, the address of the place it read, a field, element or
option payload down a path included, and the inspector shows it as at 0x…. A
data case's field and an expression root's value are not places and carry none,
rather than the address of a copy. A stack address is not offered to
flan-inspect-address, since the registry does not follow one.
DONE A defclass is not on the definitions list as a type, and a sum's cases are not drawn
CLOSED: [2026-09-25]
defs reads classes off the session's declarations, which still hold every
defclass, and lists each as kind class with its slots and location; its
constructor is not listed again as a fn. Each data case is a case row named
Type.Case, drawn whenever data is, and the data row's signature lists its
cases. CFn is in flan-mode's type rule.
CANCELLED A flycheck checker, and a structured JSON report
CLOSED: [2026-09-20] The workflow is compile-at-the-end, not live linting. Not built per the specification's own branch — the flag is the command and the printed shape is the error pattern, so anyone who wants one has the four lines, and the manual carries them.
DONE defclass slots take types, checked on write
CLOSED: [2026-09-25] Constructor parameters stay dyn and each store checks at run time; an int widens into a float slot only if it round-trips, and nil fits only an (Option T) slot.
DONE println takes up to a second to appear
CLOSED: [2026-09-25]
The daemon pushes program output on a connection that asked for pushes
((:op "push" :on t)), coalesced to at most one frame per 50ms, and the
watch table on the same channel at flan-watch-interval. Emacs reads every
frame in a process filter. The editor's watch timer and flan-settle-hook
are gone. The poll stays for the stop and park edges. Rules out a faster
poll and pushes to clients that did not ask for them. See docs/BUILT.md,
"Output and the watch table are pushed".
DONE set writes a class slot; put is for maps
CLOSED: [2026-09-25]
put on an instance still checks a declared slot's type and still inserts an
undeclared key; only set refuses one, since a slot it writes has to exist.
TODO A session eval reported (CFn [] ()) does not cross into dyn yet
At sand.flan:46:20, the :pause in (when (get state :pause) (return)),
where state is a defclass instance with a pause slot. (CFn [] ()) is
the prelude's pause's own type, so a keyword looks to have resolved to the
function of that name. Not reproduced: the file type checks, flan reload of
the same form builds, and a minimal defclass + get + return program
compiles. So it is the session path against an installed program, and what is
missing is what that daemon had installed at the time.
Also not reproduced against a live daemon (2026-09-25): an eval of the
defclass, of a defn doing the get and return, and of both in one form,
and an eval-expr of the get, all succeed. In a session every installed
function of no arguments returning () has the type (CFn [] ()), not only
pause — a bare pause or tick asked of the session says so — so the
keyword may not be what resolved. The next report wants the exact form sent.
Nor (2026-09-25) by a mark at the :pause or the when, frame evaluation, the
indented syntax, or load-file after slot changes and with a program pause.
DONE A digit does not take the restart RET takes
CLOSED: [2026-09-25]
Evil's normal state binds 0 (beginning of line), 1-9 (a count) and RET
above the major mode's map, so the digit never reached flan-cnr-take-number.
The keys flan-cnr-mode-map itself binds are given to Evil's normal and
motion states in that mode; every other key, including what special-mode-map
binds, stays Evil's. The other special-mode buffers (inspect, watch, doc, disassembly,
diagnostics, lower) have the same exposure and are not changed.
DONE Evil takes the keys in the other Flan buffers
CLOSED: [2026-09-25]
flan-evil-own-keys (flan-mode.el) gives the keys a mode's own map binds to
Evil's normal and motion states; the break, inspect, watch, doc, disassembly,
diagnostics and lower buffers all call it. The doc, disassembly and watch
maps bind q, and the diagnostics map binds RET and q, so those keys are
the mode's own and behave the same under Evil. Every key a mode does not bind
itself, including the rest of special-mode-map, stays Evil's.
DONE The stack lists prelude frames
CLOSED: [2026-09-25]
A frame whose location is <prelude> is hidden by default, and a line in its
place counts the hidden run; P shows them. A hidden frame keeps its index,
because locals and the inspector are asked by it. The innermost frame is
shown even when it is the prelude's, unless the stop is (pause), because it
is where the program stopped. Rules out renumbering the visible frames.
DONE C-c C-c reports one error, not every error in the form
CLOSED: [2026-09-25] Every error at any depth: a refused subexpression stands as a Never that fits any want, and what it causes is left unsaid. Rules out stopping at a statement boundary.
DONE There is no stepper
CLOSED: [2026-09-25] C-c C-s instruments a defn with a step point before each body form; no step into a callee, no argument positions, and no value shown after a form.
DONE A NaN cast says "does not fit", which reads as too big
CLOSED: [2026-09-25]
Two more ArithError codes: 5 for a cast of NaN and 6 for a cast of an infinity,
each with its own sentence. Both backends choose the code on the cold path, so the
guard is still two compares. lhs and rhs still carry the range. Rules out
carrying the float value in the condition.
DONE The condition buffer cannot jump to the source
CLOSED: [2026-09-25]
RET (and v) on a frame or on the stop's at line opens the file there; TAB
alone folds a frame's locals. The buffer is a next-error buffer, made current
when it opens, so M-g M-n walks the stop and then each frame with a file.
Refusals — the prelude, a relative path, a missing file — are one function
shared with M-..
DONE loop's bindings should be sequential, like let's
CLOSED: [2026-09-25]
check_loop binds each name before checking the next initialiser; recur still
rebinds all at once. No other form had the gap: let was already sequential,
dotimes binds one name, and fn, defn, match and the handler and restart
clauses bind parameters with no initialisers.
DONE A session should start before a program compiles
CLOSED: [2026-09-25]
A file with no main starts on a stub main that returns and parks; load-file (C-c C-k, already its key — the inspector stays on C-c C-i) keeps what compiles and lists the rest. Rules out flan dev with no file at all, and --two-process on a file with no main.
DONE compilation-mode steps over the notes
CLOSED: [2026-09-25]
The daemon buffer and the diagnostics buffer set compilation-skip-threshold to
0 locally, so next-error stops on a note as well as an error. The user's own
default is left alone. Rules out relabelling a note as a warning to make it
navigable.
Docs and the repository
DONE The reference page says what the language is
CLOSED: [2026-09-21]
A correction pass over web/index.html, which had been asserting that there is
no collector, that ownership is tracked statically, that types are mandatory,
that Vec and Map move on assignment, and — under "What it is not" — that
there is no dynamic typing. A dyn section, function values, the slice
arities and the three defining forms were added. Two claims are still wrong and
are carried into the Diátaxis restructure: a dyn is NaN-boxed rather than
always on the heap, and bytes-view write-through is undefined behaviour
rather than a guaranteed trap.
DONE @page was green over a needle it could never derive
CLOSED: [2026-09-21]
test/dune's page rule never named vendor/edn, which sand.flan imports, so
the headless program could not resolve the package and the quote check reported
it as drift rather than as a missing dependency. One glob_files.
TODO The reference, the tutorial, the how-to and the explanation
The four Diátaxis kinds, one org file each and nothing outside them:
reference.org absorbing spec-memory.md, spec-conditions.md and
conditions.org; howto.org from emacs/MANUAL.md; explanation.org from
docs/BUILT.md and the spikes; tutorial.org, which does not exist yet.
Published with ox-publish the way ~/Development/ferano.io does it, from a
dune rule so a broken docs change fails the build, with examples as
:tangle blocks so the code on the page is the file @page compiles.
CLAUDE.md stays markdown because Claude Code loads it by that name.
DONE dune test stays fast and the slow checks stay opt-in
CLOSED: [2026-09-14] The suite is run constantly, including by every lane, so a second added to the default run is paid hundreds of times. Rules out attaching the sanitizer, valgrind or backend sweeps to the default run.
DONE A lane runs dune test and nothing more
CLOSED: [2026-09-19] The corpus sweeps run once after several lanes have landed, and their fixes are dispatched as one batch. A survey walks the whole corpus, so a lane touching a handful of programs was paying the full cost to learn nothing about the rest. The consequence accepted is that a lane is reviewed on its code.
DONE A check nothing runs rots, so every check has an alias and CI runs them
The page's example checker and the backend survey each rotted for days or weeks because neither ran unless somebody remembered. Both are aliases now, inside the umbrella alias CI runs on every push. Both are too slow for the default suite, which is the tension that caused it.
DONE Compiler messages are written for a first-timer
CLOSED: [2026-09-21] Two rules, not one. A message says what is wrong and what to write, and stops. And it says it to someone holding this compiler and nothing else — no prior spelling, no milestone number, no rename framed as a rename. Every suggestion a message prints must compile, and an assertion about the compiler's own invariants is prefixed and says it is a compiler bug.
DONE The diagnostics pass
CLOSED: [2026-09-20]
Graded against the contract: show the code with the caret, say what was
understood, say what conflicts, name the fix. Two behaviour changes came with it —
an unannotated two-name parameter vector compiles as two dyn parameters, and a
defn named after a builtin stopped being unreachable.
WAIT Every diagnostic carries a stable kind at the end of its first line
Decided 2026-09-25: wanted, in the shape ... found str [type-mismatch]. Waits
for the documentation rewrite, which is what a kind would link to. The clause
order — understood, then the conflict, then the fix — is a rule in CLAUDE.md,
and reporting every error in a form is its own entry under the Editor heading.
DONE docs/BUILT.md still describes (Handle T) and the pool as built
CLOSED: [2026-09-25]
The section is a short past-tense record: that neither exists, why they went,
the decisions a library version would face, and that classes landed without the
enumeration the pool was built to give. A stale runtime comment naming
resolve went with it.
DONE The daemon still tells an editor the x86 backend pushes no frames
CLOSED: [2026-09-25]
The rewrite in Dev.ask is gone, so an x86 session passes the agent's reply
through as an LLVM one does. The Emacs manual and the README no longer list the
inspector as something the x86 backend cannot do.
DONE web/index.html still claims there is no implicit widening
CLOSED: [2026-09-25] Both sentences say what is true: a conversion that cannot change the number is implicit, any other is written. The rest of the page is left for its rewrite.
DONE plan.org's Types section lists a predicate that no longer exists
CLOSED: [2026-09-25]
The five predicates are the checker's five, with integer? in place of
copyable?, and the section no longer names (Handle $t) or pool-new.
DONE plan.org's Data model section still describes move-only containers
CLOSED: [2026-09-25]
The Data model section says assignment copies a container's header and the
copies alias one buffer. The memory tiers and the classes section name the pool
and generational handles as a library over a Vec, and the classes gate that
named Handle is replaced by what classes are as built. The milestone record
of what was frozen is left as history.
DONE plan.org cites the wrong mechanism for jank's relinking bug
CLOSED: [2026-09-25] plan.org now says the crash was a teardown race (the maintainer's diagnosis on issue #947), that jank already calls through vars, citing the clone, and that Flan avoids the repro by compiling out of process.
DONE docs/SPIKE-GENERICS.md lists landed work as remaining
CLOSED: [2026-09-25] The row is marked as landed, with the test that covers it, rather than removed; the report stays a dated record.
DONE Two citations in spec-memory.md do not land where they say
CLOSED: [2026-09-25]
Map_Cell_Info is cited at core.odin:351. The defer bullet cites the
defer_ok field and the Ast.Defer arm, and says what the checker accepts: a
defer in a top-level let is legal, so (defer (free v)) for a let-bound
v is expressible and the spec no longer says otherwise.
DONE The sand hash is quoted as prose in five places besides its assertion
CLOSED: [2026-09-25]
Four prose copies were left, not five. BUILT.md points at sand_out in
test_acceptance.ml instead of quoting a number. The page's two are checked by
quotes.sh against a run, so they stay. The handoff report is dated and keeps the
number it recorded.
DONE sand.flan is two programs
CLOSED: [2026-09-13] The windowed one is never executed here; the headless one is the acceptance case and runs at both optimisation levels, as a dev build, and on wasm32. The physics is untouched on purpose — the three reference implementations disagree there, so parity does not name a target.
DONE The brush is embedded, not loaded from a path
Deliberate, because a path-based load is the one shape the browser cannot have. Said so it is not later read as an accident. It holds for the shipped programs rather than for the repository — a test fixture still loads an image from a path.
DONE A package under test/programs needs a glob line in four places
CLOSED: [2026-09-25]
One corpus alias in test/dune holds (source_tree programs) and the
workspace files the corpus imports; the tests, test_web, @sanitize,
@valgrind, @x86 and @js depend on it, and @page takes the same
source_tree. A package added under programs/ needs no line anywhere. A build
that raises is a FAIL line in every sweep: the acceptance pool, the two
sanitizer binaries, and a MISSING count that fails both survey scripts.
DONE The macro programs are not in the sanitizer sweep
CLOSED: [2026-09-25]
The five that run — macros, macro-params, macro-unless, pkg-macro,
prelude-macros — are rows in test_sanitize.ml's list; the refused ones stay
out with the other negative cases. The list stays explicit rather than a glob.
Not yet run under the sweep: that waits for the batched @sanitize.
NEXT The mutation pass has not been re-run
Decided 2026-09-25: re-run it once after the second batch of 2026-09-25 merges, with the heavy sweeps, after asking the author. Sixty mutations, nineteen of which left the whole suite green; all nineteen are closed, each re-planted and watched fail against the new test. What is open is that the pass has not been run again, so nineteen is the old number.
DONE bin/main.ml spells the compile pipeline out by hand
CLOSED: [2026-09-25]
lib/front.ml holds the load, the check and the link. flan build, run,
emit (both backends), check and shim go through it with ~all:true (every error,
Loc.Errors); Test_support.checked and linked go through it without (the
first error, Loc.Error). The --no-gc and --warn-memory passes stay in the
CLI as a hook that sees the program before Reach prunes it.
DONE Build.executable returns only its output path
CLOSED: [2026-09-25]
It returns the output path and, under keep, the path of the IR or assembly
it kept; without keep that file is gone and the second half is None. The
two-process daemon moves the host's IR from the path it is given and no longer
recomputes Build.workdir.
CANCELLED The 2MB OFL font is not vendored
CLOSED: [2026-09-25] Two megabytes of history for one example that already says on screen when the font is missing and runs without it.
DONE old-ocaml/ and the built executables are untracked on purpose
The executables are what a build drops beside their sources. old-ocaml/ is the
pre-rewrite frontend, kept as reference and excluded from the build; its contents
are also in git history.
DONE A lane that stops mid-repair says which pieces it ran
A handoff once said one of five hand-offs was dropping a value; four of the five were never written at all, and the first step it recommended could not have worked because there was no comparison to print.
CANCELLED Examining branch emacs-batch-a63bd
The branch no longer exists, so there is nothing left to look at. git log is the record.