# 0141 — `List(T).append` at comptime (in a type-construction `::`) bails

> **Status: OPEN — deferred enhancement, NOT a blocker.** Building a comptime
> variant/field list with an array-literal local already works
> (`examples/0620`/`0624`); only the `List`-grown form fails. Filed to record the
> two-layer root cause for a dedicated session. Surfaces a CLEAN diagnostic, not a
> crash.
>
> **UPDATE (2026-06-18): the flat-memory VM now HANDLES this pattern via the
> compiler-API.** A type-fn that builds its members in a `List` (`.append` then
> `register_type(handle, kind, vs.items[0..vs.len])`) runs end-to-end on the VM
> (`-Dcomptime-flat`) — both 0141 blockers are gone there: lowering-time allocation
> works (the CAllocator thunks are now force-created in `runComptimeTypeFunc` and
> the VM's `materializeDefaultContext` lays their func-refs into a real context),
> and pointer field access has no slot_ptr chain (flat memory; `aggType` derefs a
> pointer `base_type`, `subslice` handles `[*]T`). What still fails is the ORIGINAL
> repro below, which uses the metatype `define`/`make_enum` (`#builtin` →
> `call_builtin` → VM falls back to legacy → legacy's slot_ptr + null-allocator
> bugs). Resolving the repro on the VM needs the metatype re-expressed over the
> compiler-API (so the type-fn hits no `call_builtin`); shipping it on BOTH gates
> needs the VM-default flip + legacy deletion. See CHECKPOINT-COMPILER-API
> (2026-06-18 "step 6").

## Symptom

One-line: a `List(T)` created and `.append`-ed at compile time inside a
type-construction `::` const bails — `comptime type construction failed:
comptime struct_get: base has no fields (not an aggregate/string/int)` — even
though the identical `List` code runs fine at RUNTIME and via `#run`.

- **Observed:** the `::` const evaluates to `.unresolved` after the interp bails
  on the first `vs.append(...)`; the user sees the construction-failed diagnostic
  plus a follow-on "cannot infer enum type for '.green'".
- **Expected:** the `List`-built variant list mints the enum exactly as the
  array-literal form does (`examples/0620`): `Color` constructs, `.green(7)`
  matches, prints `green=7`, exit 0.

## Reproduction

`issues/0141-comptime-list-growth-in-type-construction.sx` (standalone; only
`modules/std.sx` + `modules/std/meta.sx`). Run:
`./zig-out/bin/sx run issues/0141-comptime-list-growth-in-type-construction.sx`
→ bails today; the fix should print `green=7`, exit 0.

### Bisection (key signal: WHEN the comptime eval runs)

| Form | Path / eval time | Result |
|---|---|---|
| `List(i64)` append, read at RUNTIME (in `main`) | codegen | **works** |
| `v :: #run build()` where `build` grows a `List(i64)` | EMIT-time interp | **works** (`.sx-tmp/probe_list4.sx`) |
| `T :: makeListType()` where the body grows a `List` | `scanDecls`-time interp (`evalComptimeType`) | **BAILS** |
| same metatype `::` but with an array-LITERAL local instead of `List` | `scanDecls`-time interp | **works** (`examples/0620`/`0624`) |

The discriminator is eval time: `#run` evaluates at EMIT time (after the whole
program is lowered), whereas a metatype `::` const evaluates during `scanDecls`
(early, mid-lowering). Two things are not yet ready at `scanDecls` time.

It is NOT metatype/EnumVariant-specific — a plain `List(i64)` grown in a
`-> Type` body bails identically (`.sx-tmp/probe_li64.sx`).

## Root cause — REFINED (2026-06-17): wrong IR at scanDecls, not just "not ready"

Deeper investigation overturns the "two independent layers" framing below. The
real root cause is a single one: **a generic stdlib method's body is lowered to
WRONG IR when that lowering is triggered at `scanDecls` time** (during the
metatype `::` eval), because the generic struct instantiation context is
incomplete then.

Proof (instrumented `interp.zig`'s `.struct_get` / `.struct_gep` arms, ran the
same `List(i64)` append both ways):

| Eval time | `list.len` / `list.cap` (where `list: *List(T)`) lowers to | Result |
|---|---|---|
| `#run` (EMIT time, world complete) | `struct_gep` (pointer field access) — 38 hits | works |
| metatype `::` (scanDecls time) | `struct_get` (VALUE field access) — fails on the 1st | bails |

So `List.append` is fully lowered in BOTH cases (no unlowered/extern call fires),
but at `scanDecls` time `list.len` lowers as `struct_get` on the pointer VALUE
instead of `struct_gep` THROUGH the pointer — `struct_get` on a `*List` receiver
sees a `slot_ptr` whose load is another `slot_ptr` and bails. The two "layers"
below are both symptoms of this same incomplete-context lowering (the null
allocator is the same story for the CAllocator thunks).

**Consequence for the fix:** an interp-side "lazy-lower the missing function"
hook does NOT help — the function is already lowered, just to wrong IR before the
interp ever runs. The fix must ensure the bodies the metatype eval needs are
lowered with a COMPLETE type context. Two viable directions:
  1. **Make field-access lowering robust** — `list.len` on a `*List(T)` receiver
     must emit `struct_gep` whenever the receiver is a pointer-to-struct, even if
     the pointee's generic instantiation isn't finalized yet (resolve the field
     index against the in-progress instantiation). Localized to the
     field-access / generic-struct-instantiation path; risk is mis-lowering other
     in-progress generics.
  2. **Defer the comptime type-construction eval** to a dedicated pass AFTER the
     stdlib/generic machinery the constructors call is lowered, but before general
     body lowering of code that USES the constructed types (their forward slots
     are already pre-registered, so `*Name` / annotations resolve in the interim).
     This is the true "lazy/deferred" shape — the eval runs in a complete world,
     exactly like `#run`. Bigger (pipeline ordering) but matches why `#run` works.
  Decision pending (see the conversation) — direction 2 is the principled match to
  the `#run`-works/metatype-fails asymmetry.

## Implementation plan — Direction 2 (defer eval to a complete-world pass)

Chosen direction: move the comptime type-construction eval out of `scanDecls`
(Pass 1) into a new pass that runs once the world is complete enough that the
constructor bodies lower correctly.

**Pass map (`decl.zig:lowerRoot`).** Today the eval is at Pass 1 (`scanDecls`,
`decl.zig:777`). The CAllocator thunks are created at Pass 1c
(`emitDefaultContextGlobal`) — AFTER scanDecls — which is why the comptime
allocator is null. `checkInfiniteSize` (Pass 1g) and body lowering (Pass 2)
consume the constructed layouts, so the eval must finish before Pass 1g. Target
slot for the new pass: **between Pass 1c and Pass 1g** (call it Pass 1c′
`lowerDeferredComptimeTypes`).

**STEP 0 — DE-RISK FIRST (DONE 2026-06-17 — Direction 2 RULED OUT as scoped).**
Wired the minimal deferral (collect the consts in scanDecls + `preregisterForwardTypes`
eagerly; eval them in a new Pass 1c′ right after `emitDefaultContextGlobal`).
Result: the List repro STILL bailed with `struct_get` — deferring past the thunks
did NOT change `list.len` to `struct_gep`. So the wrong-IR cause is **not**
pass-position relative to `emitDefaultContextGlobal`; it's the field-access /
generic-struct-instantiation lowering itself, which only produces `struct_gep` at
**body-lowering (Pass 2) / emit** time, not at any pre-body-lowering pass. Worse,
the deferral DESTABILIZED a working case (`examples/0620` →
"define(): handle is not a declare()'d enum slot", a forward-slot/alias ordering
regression). Experiment reverted.

**Revised conclusion.** There is no single pass slot where (a) the constructor body
lowers correctly AND (b) the constructed layout is ready before code that uses it:
the body only lowers right at Pass 2/emit, but the layout is consumed *during*
Pass 2. So a simple "defer the eval" (Direction 2) can't work; it would need a
genuine two-phase scheme. The tractable path is **Direction 1** — fix the
field-access lowering so `recv.field` on a `*Struct` receiver emits `struct_gep`
regardless of when it's lowered. Next step: find why `list.len` (`list: *List(T)`)
lowers as `struct_get` (value) instead of `struct_gep` (pointer) at scanDecls —
i.e. what about the generic `List(T)` instantiation is incomplete then that flips
the field-access decision (start at `lower/expr.zig:lowerFieldAccess` /
`lowerFieldAccessOnType` and the `*T`-receiver path).

## Root cause — RE-REFINED (2026-06-18): the IR is CORRECT; it's a legacy slot_ptr chain + null comptime allocator

Direction 1's premise is **wrong** — the IR is NOT mis-lowered. Instrumented
`lowerFieldAccess` (the `*T`-receiver auto-deref) on the repro: `list.len` lowers
with `obj_ty = *List__EnumVariant` (kind=**pointer**), so the auto-deref fires
(`obj = load(list, List); struct_get(obj, len_idx)`) — **byte-identical** to the
runtime/`#run` shape. There is no `struct_get`-vs-`struct_gep` divergence in the
READ path (field reads ALWAYS lower to load+`struct_get` via `expr.zig:~915`;
`struct_gep` is only the WRITE/lvalue path — the "38 hits" above were writes).

The bail is purely in the **legacy interp's slot_ptr semantics**. Instrumented the
`.struct_get` arm: for `list.len` the base is a `slot_ptr`, and the arm's own
`slot_ptr` auto-deref (`loadSlot`) yields **another `slot_ptr`** (a `*List` param
→ slot_ptr → slot_ptr chain), which no `switch(base)` case resolves → "base has no
fields". So `load(*List)` produced a slot_ptr the interp can't flatten to the
aggregate at this call shape.

There is a SECOND, independent blocker that survives even if the slot_ptr chain is
fixed: `List.append` grows on the first call (cap 0→4) → `context.allocator.alloc_bytes`
→ `call_indirect` on a **null `alloc_fn`** at lowering time (the comptime allocator
is null at type-construction time — confirmed in BOTH evaluators; see the
CHECKPOINT-COMPILER-API 2026-06-18 entry). So the repro needs BOTH the slot_ptr
chain AND the comptime allocator fixed.

**Strategic implication (2026-06-18).** Both blockers are LEGACY-interp issues
(slot_ptr chains; null comptime allocator dispatch — "raw fn-pointers from extern
calls aren't dispatchable in interp"). The flat-memory comptime VM (`comptime_vm.zig`)
has neither failure mode by construction: it uses flat byte memory (no slot_ptr
chains) and models `malloc`/`call_indirect` natively. So the principled fix is NOT
to patch the legacy interp (code slated for deletion), but to let the VM evaluate
these type-fns: (a) re-express the metatype `define`/`make_enum` over the
compiler-API so the type-fn body hits NO `call_builtin(define)` (which forces a
legacy fallback today), and (b) make `materializeDefaultContext` lay the REAL
allocator func-refs at lowering time (the global exists by Pass 1c, so the
`layoutConst` path should populate them — verify it handles the inline-protocol
`Allocator` field, which may need a `.protocol`/inline-struct arm). Then a
List-building type-fn runs entirely on the VM. The remaining tension is dual-path
validation: while the legacy still fails, a corpus example can't pass gate-OFF — so
this lands cleanly only alongside the VM-default flip + legacy deletion (the
end-state). Until then it stays a deferred enhancement, not a blocker.

**Plumbing (only after STEP 0 is green):**
1. `scanDecls` (`decl.zig:777` site): instead of `evalComptimeType` now,
   (a) pre-register a forward nominal slot named `cd.name` + bind the alias
   `cd.name → slot` (so `c : Color`, Pass 1f's UnknownTypeChecker, etc. resolve in
   the interim), and (b) push `{ name, value, source_file }` to a new
   `deferred_comptime_types` list on `Lowering`. Don't eval.
2. New `lowerDeferredComptimeTypes` pass, called from `lowerRoot` after
   `emitDefaultContextGlobal` and before `checkInfiniteSize`: for each entry, set
   `current_source_file`, `const tid = evalComptimeType(value)`, `putTypeAlias`.
   The interp's `declare("Color")` finds the pre-registered slot (findByName) and
   `define` fills it in place (`updatePreservingKey`), so `tid` == the forward slot
   — alias stays valid.
3. Self-ref (`List :: make_list()` + `*List`): the forward slot for `List` is
   registered in step 1, so `*List` resolves while `make_list`'s body lowers during
   the deferred eval. Verify `examples/0618` still passes.

**Risks / watch:**
- **Name mismatch.** The minted type's name comes from `declare("X")` inside the
  ctor, not the LHS `cd.name` (`decl.zig` comment "no rename"). For the
  non-generic `::` path the two normally coincide, but if `declare`'s string ≠
  `cd.name` the pre-registered forward slot is orphaned (empty tagged_union →
  could trip the declare-never-defined / infinite-size checks). Handle: either
  require the names to match here, or reconcile the orphan after eval.
- **Generic type-fns** (`RecvResult($T)`) go through `instantiateTypeFunction`, a
  DIFFERENT site (lazy, at use) — leave those as-is; only the non-generic `::`
  site (`decl.zig:774`) defers.
- Re-run the FULL suite: every existing metatype example (0614–0624, 1178–1182)
  must stay green — the deferral changes *when* they evaluate.

## Root cause — TWO independent layers (SUPERSEDED by the refinement above)

### Layer 1 — null comptime allocator (has a known fix)

`src/ir/interp.zig:defaultContextValue` builds the comptime `context.allocator`
by looking up the CAllocator→Allocator protocol thunks BY NAME in the module's
functions:

```zig
const alloc_thunk_name = tbl.internString("__thunk_CAllocator_Allocator_alloc_bytes");
// ... scan self.module.functions for that name ...
```

At `scanDecls` time those thunks aren't lowered yet, so `alloc_fn` / `dealloc_fn`
stay `.null_val` and ANY comptime allocation (List growth, direct
`context.allocator.alloc`) fails. Confirmed with a debug print: metatype path →
`alloc_fn=null_val`; `#run` path → `alloc_fn=func_ref`.

**Fix (verified for this layer):** force the thunks to exist before the interp
runs, in `src/ir/lower/comptime.zig:runComptimeTypeFunc`, guarded exactly like
`emitDefaultContextGlobal` (skip when Allocator/CAllocator aren't registered):

```zig
const tbl = &self.module.types;
if (tbl.findByName(tbl.internString("Allocator")) != null and
    tbl.findByName(tbl.internString("CAllocator")) != null)
{
    _ = self.getOrCreateThunks("Allocator", "CAllocator");
}
```

`createProtocolThunk` saves/restores builder state (`saved_func`/`saved_block`/
`saved_counter`), so calling it mid-lowering is safe (same as
`emitDefaultContextGlobal`). After this, `alloc_fn=func_ref` — but layer 2 still
bails.

### Layer 2 — `struct_get` through a `*T` slot_ptr chain (the deep part)

With the allocator fixed, `vs.append(…)` still bails. `List.append` takes
`self: *List`; the `vs.append(…)` UFCS desugars to `append(@vs, …)`, so inside
`append` the receiver `self` is a `*List`. At comptime it lands as a frame slot
whose CONTENTS are a `slot_ptr` to the actual `List` value, so `self.field` does
`struct_get` on `base=slot_ptr field_index=1` and falls through to the bail.

`src/ir/interp.zig`'s `.struct_get` arm auto-derefs a `slot_ptr` base with a
SINGLE `loadSlot` (+ `resolveFieldLoad` for field-pointer aggregates). A
chain-resolve loop (`while (loaded == .slot_ptr) loaded = loadSlot(...)`) did NOT
fix it: the final loaded value is a field-pointer aggregate that
`resolveFieldLoad` turns back into a `slot_ptr`. List's comptime in-memory
representation mixes field-pointers and slot_ptrs that the `struct_get` /
`resolveFieldLoad` path doesn't fully resolve for a `*T` receiver.

This is the substantive work: comptime pointer/struct/slot resolution for `*T`
struct receivers — its own focused interp session.

## Investigation prompt

> A `List(T)` grown at comptime inside a type-construction `::` bails
> ("struct_get: base has no fields"), though the same code works at runtime and
> via `#run`. Repro: `issues/0141-comptime-list-growth-in-type-construction.sx`
> (expect a bail today; the fix should print `green=7`, exit 0).
>
> It's two layers (see this file's Root cause). START with layer 1 (the known
> fix: force `getOrCreateThunks("Allocator","CAllocator")` in
> `comptime.zig:runComptimeTypeFunc` before the interp runs, guarded like
> `emitDefaultContextGlobal`). Verify with a debug print that `defaultContextValue`
> then sees `alloc_fn=func_ref`.
>
> THEN layer 2 (the real work): make the interp's `.struct_get` (and
> `index_get`/store paths) resolve a `*T` struct receiver whose slot holds a
> `slot_ptr` to the value. Reproduce in isolation with a plain non-generic
> `Box :: struct { x: i64; }` and a `bump :: (b: *Box) { b.x += 1; }` called at
> comptime, so you debug the pointer-receiver `struct_get` without List's
> generics. Trace what `frame.getRef(fa.base)` / `loadSlot` / `resolveFieldLoad`
> return for `self.field` and make the deref fully resolve to the backing
> aggregate (mirror `resolveSlotChain`, but for the field-pointer + slot_ptr mix
> that a `*T` receiver produces). Don't add a silent fallback — bail loudly if a
> shape still isn't handled (per CLAUDE.md REJECTED PATTERNS).
>
> Verification: the repro prints `green=7`, exit 0; then `zig build && zig build
> test` green. Move the repro to `examples/06xx-comptime-metatype-make-enum-list.sx`
> (resolving-an-issue workflow) and add a focused `*T`-comptime-receiver example
> too. Update `current/CHECKPOINT-METATYPE.md` (the last deferred enhancement).

## Notes

- Bail site (symptom): `src/ir/interp.zig` `.struct_get` arm, `else =>` →
  "struct_get: base has no fields".
- Layer-1 site: `src/ir/interp.zig:defaultContextValue` (thunk-by-name lookup);
  fix in `src/ir/lower/comptime.zig:runComptimeTypeFunc`.
- Layer-2 site: `src/ir/interp.zig` `.struct_get` auto-deref (single `loadSlot` +
  `resolveFieldLoad`); `*T` receiver slot_ptr chain unresolved.
- Both layers reproduce with a plain `List(i64)` — not metatype-specific. The
  metatype `::` path just happens to be the first `scanDecls`-time comptime eval
  that needs heap allocation.
- Workaround (no fix needed for callers): build the variant/field list with an
  array-literal local — `examples/0620` / `0624` already do this.
