Strike steps 1 to 3, and say in BUILT.md why the built thing is that shape

NEXT.md is what is left, so the three steps go, the four questions that were
unsettled under them are answered or replaced, and the reasoning that outlives
the change moves to BUILT.md: why Allocator being a builtin opaque type made
milestone 5 unnecessary, why with-allocator is its own IR node, why the move
rule unions at a join, what ownership is not transitive through yet and what
each refusal waits on, and why StorageExhausted had to land with Vec.

Four things replace the old open list. The use-after-release question is still
open but now has evidence available for the first time, because there is a Vec
to write arena programs with. The release layout is blocked on the reload path
carrying build flags. The generation word has no reader and will not have one
until a slice can carry a Vec's identity. And the budget is an addition to the
spec that wants folding in or replacing with a growable arena.
This commit is contained in:
Joseph Ferano 2026-09-12 11:15:22 +07:00
parent 5aa6c16209
commit 92ca8c9774
2 changed files with 277 additions and 42 deletions

215
BUILT.md
View File

@ -1127,6 +1127,221 @@ of those calls goes through a cell.
memory of the process it is talking to, which is only guaranteed if it is the session that compiled the running binary.
Attaching to a process someone else built is not a thing to support by default.
## Allocators, `(Vec T)` and `StorageExhausted`
`spec-memory.md`'s Allocators section is frozen and settles what to build. This is why the built thing is the shape it
is, and — separately and loudly — the three places it **amends** that section plus the one thing it adds to it.
### `Allocator` is a builtin opaque type, so none of milestone 5 was needed
The spec defines an allocator as "a procedure plus an opaque data pointer", which reads as a function value, and
`check.ml` refuses function values four ways — a written `(Fn ...)` annotation, a written `fn` literal, a `defn`'s name
in value position, and calling anything other than a named function. Read straight off those lines, containers need
function values and the work doubles.
None of the four is anywhere near this. All four are about *surface syntax*, and a value the compiler builds that no
surface form names trips none of them:
- `Allocator` is a `Types.t` case with no user-writable constructor, the way `string` is a builtin ptr+len. No Flan
type names its procedure.
- The procedure is a C symbol the emitter names.
- `vec-new`, `push`, `free`, `free-all` and the rest are ordinary named calls, which `check_call` already routes
through `named_call`.
The precedent was already in the repo twice: a `handler-bind` clause is lowered to a function called back through
`h->fn(condition, xfer)` and built as its own `Tast.fn`, never an `Ast.Fn`; and a dev build's `call` loads a pointer
out of a cell and calls through it, which is the indirect call the surface language refuses.
What *does* need milestone 5 is a **user-written** allocator: "here is my proc, make an `Allocator` from it" wants a
`defn`'s name in value position. `make-allocator`, `allocator-from` and `allocator` are refused by name with that
reason, rather than coming back as unknown functions.
**An `Allocator` value is a pointer to the runtime's struct, never a copy of one.** That is forced, not chosen. The
capability set has to be readable from wherever a container landed, and `free-all` bumps an epoch every container made
from the allocator has to observe. A copied-by-value allocator gives each copy its own epoch and the dev trap never
fires.
### The surface
| Name | What |
|---|---|
| `(heap-allocator)` | the general-purpose tier: malloc, aligned, with free |
| `(arena-new bytes)` | a bump arena over one fixed backing buffer |
| `(arena-destroy a)` | hands the pages back — see the amendment below |
| `(free-all a)` | releases everything the allocator holds, retain-capacity |
| `(can-free? a)` / `(can-free-all? a)` | the capability set, read at run time |
| `(alloc-epoch a)` / `(alloc-id a)` | the counter `free-all` bumps; the allocator's identity |
| `(alloc-budget a)` / `(set-alloc-budget a n)` | a ceiling on live bytes — an addition, see below |
| `(alloc-live-blocks a)` | "did you forget to free", answered at the tier that can answer it |
| `context/allocator` / `context/temp` | the current implicit allocator, and the per-frame arena |
| `(with-allocator a body...)` | rebinds for a dynamic extent and releases nothing |
| `(vec-new T)` / `(vec-new T a)` / `(vec-new)` | a Vec, against the context or a named allocator |
| `(push v x)` / `(reserve v n)` | `Unit`, both |
| `(at v i)` / `(len v)` | the array names, extended — not a parallel pair |
| `(as-slice v)` / `(as-slice v lo hi)` | a non-owning `[T]` view |
| `(clone v)` / `(clone v a)` | the only copy; assignment moves |
| `(free v)` | consumes its argument |
### Three amendments to a frozen spec, and one addition
**1. `free-all` is retain-capacity, and `arena-destroy` is the operation that hands pages back.** The spec's table has
`free-all` and nothing else. Zig's `ArenaAllocator.reset` takes a `ResetMode` of `free_all` / `retain_capacity` /
`retain_with_limit`; Odin's `arena_free_all` is retain-capacity in effect, because its arena is one fixed backing
buffer and the call only sets `offset = 0`. For a frame arena reset every frame, retain-capacity is the normal case and
handing the pages back only to ask for them again is the unusual one. Taking the mode as a parameter would have grown
the table the spec froze at four names; a second operation does not. The epoch is bumped either way — the pages being
the same does not make a container built before the reset valid, which is the whole point of the trap.
**2. `context/allocator` and `context/temp` are dynamic variables with save and restore, not extra parameters.** The
spec says the allocator is "part of the calling convention". The literal reading touches every function signature, the
FFI shim, the dev trampolines and the reload ABI, for the same observable behaviour, and it collides with every other
lane working in `emit.ml`. The dynamic variable is the implementation; the literal reading is deferred and is a
performance question (a parameter avoids a global load), not a semantic one.
**3. The `Vec` header is six words in every build, not four in release.** The spec fixes the release layout at
`ptr + len + cap + allocator` with the generation word and the epoch dev-only. A layout that changes with a build flag
is a layout that can disagree *silently* across the reload boundary: a redefinition module is built by `llc` and `ld`
against a host built separately, and nothing makes the two agree on a struct size. So the words are unconditional and
so is the epoch check. The 32-byte release layout is deferred on that, and it needs the reload path to carry the flag
before it can land.
**The addition: a budget.** `flan_allocator` grew a ceiling on live bytes, 0 for none. The spec's `retry` restart is
answerable only by a handler that can make the *same* request succeed, and for a fixed backing store the handler that
works is the one that raises the ceiling — releasing the region the container lives in invalidates the container, which
is exactly what the epoch check catches. The spec names "grows the arena and then invokes `retry`" as the handler that
works; something has to be growable for that sentence to be true. It doubles as how a test exhausts an allocator on
purpose.
### `with-allocator` is its own IR node because of the transfer path
Save, run, restore — and *restore again at the pad*. That second restore is the whole reason it is a node rather than a
`let` and two calls. A body that errors, or one a handler transfers out of, leaves through `current_pad`, and a context
allocator left pointing into a region nobody outside the body has heard of would be wrong in the break loop, which is
precisely where something is about to allocate in order to render a condition. `test/programs/allocators.flan` asserts
that path by taking a restart out of a `with-allocator` body.
It releases nothing, per the spec: not at the end of a `let`, not at the end of a function, not at the end of the body.
The program proves it by reading the epoch either side.
### `(Vec T)`: one type-erased runtime, and the backend learned almost nothing
The element type appears nowhere below the call site. `size_of` and `align_of` are produced where the concrete type is
known — which without generics is simply the concrete call site — and passed in, which is Odin's arrangement
(`base/runtime/dynamic_array_internal.odin`). The backend grew four prims in total:
- `Rt of string` — a call into the runtime's C named by symbol, with argument and result LLVM types read off the
expression nodes. The container runtime is type-erased and therefore *is* a list of C entry points, so one arm covers
all of them. A `Vec` argument crosses as its address, which is also what lets an operation mutate the caller's Vec.
- `SizeOf` / `AlignOf` of a type, filled in from the same layout calculator DWARF uses — the one the acceptance test
already checks against LLVM's own `getelementptr` answers.
- `AddrOf` of any expression, place or not, because the element a `push` copies may be computed. The backend already
spilled a non-place to a temporary for exactly this.
`at` and `len` were already the names for a fixed array and a slice, so a Vec extends them rather than adding a
parallel pair — the asymmetry `nth` was removed for. The value form `(at v i)` and the place form `(set (at v i) x)` go
through one helper, so they cannot drift apart the way `nth` did.
A Vec's length and index are `i32`, like every other length here. Widening indices is one change across every
container and not a Vec question.
`let` has no type annotation — `parse.ml` settles that a triple binding is ambiguous and that types are inferred — so a
local Vec has nowhere to say what it holds and the element type is written at the call: `(vec-new i32)`. This is *not*
the explicit instantiation syntax the generics section rules out: nothing here is generic, and the name resolves as an
ordinary type rather than binding a type variable. Where the context does say — a `defvar`'s type, a return type, an
argument — it may be left out.
A zeroed Vec has a null allocator, and the first operation that needs storage adopts the context allocator, which is
Odin's behaviour. The alternative was refusing a Vec-typed struct field outright; that is refused anyway, for a
different reason (below), but the adopt rule is what makes `Zero` of a Vec a usable value rather than a null deref.
### Move-only is a dead set, and it is flow-sensitive at a join
Reading a move-only local is a move unless the site said it was a borrow. That is the conservative direction: passing
one to a function, binding it, returning it and `free`ing it are all moves and all reach one place, and the handful of
operations that only look at a container (`at`, `len`, `as-slice`, `push`, `reserve`, `clone`) say so. Only a
*syntactically simple* target counts as a borrow — in `(len (f v))` the call still moves `v`.
At an `if` and at a `match`, every arm is checked from the state before the form and the **union** of what they moved
survives the join. A flat set is wrong in both directions: it refuses `(if c (free v) (free v))`, which is legal, and it
accepts a use after a one-armed move, which is a use-after-free. The arms are alternatives, and that is what a union
says.
The one case a dead set cannot answer is a move inside a loop: merged once at the end of the body it counts one move,
not two, while the second iteration would use what the first gave away. So it is a rule rather than an inference — a
move of a binding declared outside the loop is refused, with that as the reason.
### What ownership is not transitive through yet, and why each is refused
The spec says ownership is structural — a struct containing a Vec is itself move-only, `free` recurses into owning
fields, and a field cannot be freed on its own. That machinery is the recursive teardown `drop` brings. Until it lands,
three shapes are refused where they are declared, each naming `drop`:
- **a struct field of `Vec` type**, because the struct copies its header on assignment and nothing records a move;
- **a global of `Vec` type**, because the dead set is per function — two functions each freeing it is a double free
nothing could see, and a global read does not go through the move path at all, so even the one-function case would be
accepted. Half a rule is worse than none. A global **`Allocator`** is a different thing and stays legal: an allocator
is a copyable opaque handle, and it is what makes a handler that owns the arena expressible, since a handler cannot
see the locals of the function that established it;
- **a `Vec` of a `Vec`**, because the type-erased runtime copies and releases elements bytewise: `clone` would
duplicate inner headers instead of copying what they own and `free` would drop their buffers.
And a `Vec` does not cross to C: handing a header that owns storage to C hands out an owner. `(as-slice v)` as
`(Ptr T)` plus `(len v)` is the shape that does cross, and the refusal says so.
### `StorageExhausted` went in *with* `Vec`, not after it
No allocating operation returns an error and none can fail silently. When the allocator cannot satisfy a request the
operation signals `(StorageExhausted {:bytes n :align a :allocator id})` with `error` — whose type is `Never` — inside a
`restart-case` offering `retry`. One rule over every allocating operation, which is what keeps `push` and `reserve` at
`Unit`, `clone` at the container, and no signature anywhere growing a `Result`.
It had to land with step 2 rather than after it: retrofitting adds a transfer check to every call site of every
allocating operation, which is the point of having decided it first. Odin's `append` returns an ignorable
`Allocator_Error`, and its type-erased path returns the old length on a failed reserve; an append that appends nothing
and says nothing is the outcome this rule exists to make impossible.
The lowering is built out of nodes that already existed, so the backend learned nothing about allocation:
```
(let [ok false]
(while (not ok)
(restart-case
(do (set ok ATTEMPT)
(if (not ok) (error (StorageExhausted {...}))))
(retry []))))
```
A handler that frees something, releases a scratch region or raises the ceiling and then invokes `retry` lands in the
clause, the clause falls through, and the `while` re-attempts the **same** request. Every argument to the attempt is
bound to a slot before the loop, so a retry re-attempts the allocation and not the expression that produced the value a
`push` was given. With nothing handling it, `error` stops the program on the frame that erred.
This is the named exception to plan.org's "restarts go at the resync point, once" — the restart is established at the
failing allocation, because a restart at some outer loop cannot re-attempt an allocation and only the allocation site
can. The condition is a value struct with fixed numeric fields and **no rendered message**, because formatting would
allocate and this is the one path that must not; the numbers of the failed request are read back out of the runtime.
### The epoch trap, which is the shipping answer to an open question
`spec-memory.md` leaves "catching a use-after-release statically" open on purpose: the static rule needs to know which
allocator a construction used, and `with-allocator` plus `context/allocator` are exactly the mechanisms that deny that
knowledge. The shipping answer is detection. A Vec records the epoch of the allocator it was made with, `free-all` bumps
that counter, and any operation on a container whose recorded epoch has moved traps naming the site.
`test/programs/stale-region.flan` is the case, and the point of it is that `v` is still in scope, still looks fine, and
nothing marked it — which is precisely what a static rule cannot see.
**The generation word has no reader.** It is bumped on every reallocation, as specified, and the stale-slice trap it
exists for is not implemented: a slice is ptr+len and has nowhere to carry the Vec's identity or its generation. Said
plainly here rather than implied by the word's presence in the header.
### What this leaves for steps 4 to 7
`(Map K V)`; `drop` and with it the transitive move-only rule, recursive teardown, and the refusal to construct a
drop-carrying container against an allocator without `can-free`; `(Result T E)` and `try`; generics; the macro expander.
And the **accumulation pattern**`(fn [c] (push errors c) ...)` over an enclosing Vec — which `Vec` does not buy:
capture does not exist at all, and the spec's captured-`Vec`-by-pointer rule has never had to exist because every
capturable type today is a value type. It is its own item and should be planned as one.
## Where build time goes
`flan build calc-me.flan` was ~160ms, and ~95% of it was clang. **The object cache is in**, and it is now ~110ms:

104
NEXT.md
View File

@ -62,6 +62,16 @@ the commit that made it.
`-2851001042534928384` — the same 64 bits, printed unsigned now that `hash-grid`'s `u64` no longer goes through an
`(i64 …)` cast — and a trap column shifted because the call it names got shorter.
### Landed — the allocator, the arena, `(Vec T)`, `StorageExhausted`
The critical path, and the thing NEXT.md said was the only one standing between this and writing a game. Steps 1, 2 and
3 of the build order below are struck; `Map` is step 4 and is untouched. `Allocator` is a builtin opaque type and
needed nothing from milestone 5, which was the whole bet. Three amendments to a **frozen** `spec-memory.md`, made
deliberately and stated as amendments in [`BUILT.md`](BUILT.md): `free-all` is retain-capacity with `arena-destroy`
beside it; `context/allocator` is a dynamic variable rather than a literal calling-convention parameter; and the `Vec`
header is six words in every build rather than four in release. One addition the spec does not have: a budget on the
allocator, because `retry` needs a handler that can make the *same* request succeed.
### Landed — the runtime under a sanitizer
`--sanitize` is a build flag beside `--debug`; `dune build --root . @sanitize` builds twenty-eight programs twice, plain
@ -321,8 +331,10 @@ What stopped it, and neither is small:
plan.org's single line on it (831) names a `for` the language does not have and gives no mechanism.
1. **Allocators, then `Vec` and `Map`.** The critical path, and the only thing standing between this and writing a
game. **`Vec` does not need generics** — that was wrong and is worth un-learning: Odin's containers are compiler
1. ~~**Allocators, then `Vec` and `Map`.**~~ **Steps 1, 2 and 3 are done** — the allocator, the arena, `(Vec T)`,
`StorageExhausted` and `retry`. `Map` is step 4 and is what is left of this item. See *Allocators, `(Vec T)` and
`StorageExhausted`* in [`BUILT.md`](BUILT.md) for the shape, the three amendments to a frozen `spec-memory.md` and
the one addition. The claim below held: **`Vec` does not need generics** — that was wrong and is worth un-learning: Odin's containers are compiler
builtins over a *type-erased* runtime (`base/runtime/dynamic_array_internal.odin`), where `$T` appears only in thin
wrappers producing `size_of`/`align_of` at the call site, and per-key hash and equality are compiler-emitted
procedures passed as a runtime argument (`Map_Info`, `base/runtime/core.odin:369`). That runtime is what
@ -349,14 +361,18 @@ plan.org's single line on it (831) names a `for` the language does not have and
### `Vec` and `Map` — the order to build them in
**Steps 1, 2 and 3 are built; 4 to 8 are what is left.** The reasoning is kept because it is what the remaining steps
rest on, and because the escape it describes was tested rather than assumed — see *Allocators, `(Vec T)` and
`StorageExhausted`* in [`BUILT.md`](BUILT.md).
The dependency nobody had written down, and the reason it looked worse than it is. `spec-memory.md` defines an
allocator as "a procedure plus an opaque data pointer" — a function value. `check.ml` refuses function values four
ways, and all four say milestone 5: a written `(Fn ...)` annotation (`Ast.Tfn`, line 209), a written `fn` literal
(`Ast.Fn`, 458), a `defn`'s name used as a value (571), and calling anything other than a named function (1023).
`(Vec T)`, `(Map K V)`, `(Result T E)` and `(Handle T)` are refused at 215218 as milestone 6. Read straight off those
lines, milestone 6's allocators need milestone 5's function values and the work doubles.
ways, and all four say milestone 5: a written `(Fn ...)` annotation (`Ast.Tfn`), a written `fn` literal (`Ast.Fn`), a
`defn`'s name used as a value, and calling anything other than a named function. `(Map K V)`, `(Result T E)` and
`(Handle T)` are still refused beside those as milestone 6; `(Vec T)` is not, any more. Read straight off those lines,
milestone 6's allocators need milestone 5's function values and the work doubles.
**The escape is real and the work does not double.** All four refusals are about *surface syntax*, and a value the
**The escape is real and the work did not double** — this is the claim the built thing confirms. All four refusals are about *surface syntax*, and a value the
compiler builds that no surface form names trips none of them. The compiler already does exactly this, twice:
- A `handler-bind` clause is lowered to a function whose address goes into a `flan_handler` and is called back through
@ -386,28 +402,27 @@ and that order cannot hold: the macro expander is blocked on `Form` being a Flan
numbers are a topological hint, not a sequence. Take 6's container half first, 5's generics half second, and 5's
expander last, on 6's unions.
1. **`Allocator` and the arena.** The builtin type, the four operations (`alloc`, `resize`, `free`, `free-all`) with
`size` and `align` as parameters, the capability set with `can-free` in it, `with-allocator`, and the dev-build
epoch counter. No container yet. Odin reads its capability set back through the same procedure — `Query_Features`
returning an `Allocator_Mode_Set` (`core/mem/allocators.odin:315`) — and a field on the allocator value is the same
information without the round trip.
2. **`(Vec T)`**, over the type-erased runtime: `ptr + len + cap + allocator` in release, plus the generation word and
the epoch in dev. `push`, `reserve`, `at`, `len`, `as-slice`, `free`, `clone`. `size_of`/`align_of` are produced at
the call site, which here is simply the concrete call site, there being no generics yet.
~~1. **`Allocator` and the arena.**~~ **Done.** The builtin opaque type, the four operations with `size` and `align`,
the capability set read off the allocator value, `with-allocator`, `context/allocator`, `context/temp` and the epoch
counter. `free-all` was decided as retain-capacity with `arena-destroy` beside it; the context is a dynamic variable
rather than a literal calling-convention parameter; both are stated as amendments in `BUILT.md`. A user-written
allocator is refused by name with milestone 5 as the reason.
~~2. **`(Vec T)`**~~ **Done**, over the type-erased runtime, with `push`, `reserve`, `at`, `len`, `as-slice`, `free` and
`clone`, and with move-only enforced by a dead set that unions at an `if` or a `match` join. `at` and `len` were
extended rather than duplicated. The header is six words in *every* build, not four in release — a layout that
changes with a build flag can disagree silently across the reload boundary — and that is the third amendment.
Ownership is not transitive yet, so a struct field of `Vec` type, a global `Vec` and a `(Vec (Vec T))` are each
refused where they are declared, naming `drop` as what they wait on.
The note below still stands and is now the only thing between `Vec` and the accumulation pattern: **capture does not
exist at all.** Nothing about it changed.
~~3. **`StorageExhausted` and `retry`**~~ **Done, with step 2 and not after**, exactly for the reason given. It is a
`while` around a `restart-case` around the attempt, built in the checker out of nodes that already existed, so the
backend learned nothing about allocation. `test/programs/exhausted.flan` exhausts an allocator for real and takes the
restart; `exhausted-unhandled.flan` is the same failure with nothing handling it.
**`Vec` alone does not buy the accumulation pattern, and it is worth knowing that before step 2 is scoped.**
`(fn [c] (push errors c) ...)` over an enclosing `(Vec ParseError)` is the pattern `handler-bind` exists for, and it
needs two further things. Capture does not exist at all: `check.ml`'s `captured` (`:176`) consults `ctx.outer` only
to raise a better refusal — "a handler cannot see %s ... Use a global, or pass it on the condition" — and `lookup`
(`:167`) never reads `outer`. That is `spec-memory.md`'s case 2, a non-escaping `fn` capturing by value into a stack
environment, and it is unbuilt. On top of it, the same spec says a captured `Vec` or `Map` is captured **by pointer,
not moved**, and every capturable type today is a value type, so the by-value/by-pointer split has never had to
exist in a capture path. Both are their own work and neither falls out of `Vec`. Step 2 delivers a container;
accumulating into one from a handler is a separate item and should be planned as one.
3. **`StorageExhausted` and `retry`, with step 2 and not after.** `restart-case` and the transfer channel exist, so
this is a compiler-emitted restart at each allocating site and little else. It goes in at the same time because the
signatures depend on it: retrofitting it later adds a transfer check to every call site of every allocating
operation, which is the whole point of having decided it now.
4. **`(Map K V)`** — flat open-addressed key and value arrays, with a compiler-emitted hash and equality pair per key
type passed as arguments. `spec-memory.md`'s structural-key restriction holds this to the built-in key set, so there
is no dispatch to design.
@ -421,21 +436,26 @@ expander last, on 6's unions.
of the second.
8. **The macro expander**, last, on 6's unions.
**What is genuinely unsettled, and none of it blocks step 1.**
**What is genuinely unsettled.**
- `spec-memory.md`'s "Open: catching a use-after-release statically" is open by decision, not by omission. It says the
static rule needs to know which allocator a construction used and that `with-allocator` plus `context/allocator` are
exactly what deny that knowledge; the shipping answer is the dev-build epoch trap, which is specified and buildable.
It also says what would settle it — real Flan programs using arenas, to show whether the escapes that occur are
lexical — and that is evidence this repo cannot produce until after step 2. **Build against the epoch trap.**
- **The operation table may be one operation short.** It has `free-all` and nothing else. Zig's `ArenaAllocator.reset`
takes a `ResetMode` of `free_all`, `retain_capacity` or `retain_with_limit`
(`lib/std/heap/arena_allocator.zig:57``69`), and for a frame arena reset every frame, retain-capacity is the normal
case and free-all is the unusual one — handing the pages back to the backing allocator only to ask for them again.
Odin's `arena_free_all` is retain-capacity in effect, because its arena is one fixed backing buffer and the call only
sets `offset = 0` (`core/mem/allocators.odin:287`). Flan should decide whether `free-all` means either of these or
takes the mode. It is an amendment to `spec-memory.md`, it is small, and it is better made before the arena is
written than after.
- `spec-memory.md`'s "Open: catching a use-after-release statically" is still open, and it is now open with evidence
available for the first time: the epoch trap is built and `test/programs/stale-region.flan` is the case it catches.
What the spec says would settle it — real Flan programs using arenas, to show whether the escapes that actually occur
are lexical — is now *producible*, because there is a `Vec` to write them with. That is the next thing to look at,
not the next thing to build.
- ~~**The operation table may be one operation short.**~~ **Decided.** `free-all` is retain-capacity and
`arena-destroy` hands the pages back — two names rather than the mode parameter, so the table the spec froze at four
operations did not grow. `BUILT.md` states it as the amendment it is.
- **The `Vec` header is six words in release too, and should not stay that way.** The 32-byte layout the spec fixes is
blocked on one thing: a redefinition module is built by `llc` and `ld` against a host built separately, and nothing
makes the two agree on a struct size. Give the reload path a way to carry the build flags and this falls out.
- **The generation word has no reader.** It is bumped on every reallocation as specified, and the stale-slice trap it
exists for needs a slice that can carry the Vec's identity — a slice is ptr+len. Either slices grow a word in a dev
build or the trap does not exist; today it does not.
- **The allocator grew a budget** (`alloc-budget` / `set-alloc-budget`), which `spec-memory.md` does not have. It is
there because `retry` is only answerable by a handler that can make the *same* request succeed, and for a fixed
backing store that handler is the one that raises the ceiling — releasing the region the container lives in
invalidates the container. Worth folding into the spec or replacing with a growable arena.
- Escaping closures are still deferred (`spec-memory.md`, "Function values", case 3), and a user-written allocator is
not one — its procedure is a top-level `defn` with no captured environment. The two should not be conflated when
function values arrive.