2026-08-26
Update 50's ceremony stopped at the gate. codexzig_build.sh — the
crown-jewel artifact that compiles the compiler's own chapter set through
the zig plug and then tries to build the result — emitted 47 copies of
the same zig error:
error: use of undeclared identifier 'T38'
T38 was declared, 73 times, as comptime T38: type. The 47 failures used
it somewhere else entirely, in functions that take no comptime parameters
at all. A type variable was escaping the definition that quantified it and
landing in a scope that had never heard of it.
This note is what it turned out to be, and the answer to the question worth asking about any defect a new instrument finds on its first outing: did we just break this, or did we just look?
The short answer is we just looked, and the evidence is unusually clean. The longer answer has a sting in it, which is the part I'd keep.
The whole defect fits in five lines of intermediate representation. Here is a thirty-line Codex program whose two rows differ in exactly one thing — whether the mapped function has a name — compiled by the seed:
map-list : (tvar 23 -> tvar 24) -> (List (tvar 23) -> List (tvar 24))
mapped-named : apply (apply map-list bump) xs -- bump : int -> int
mapped-lambda : apply (apply map-list __lam_0) xs -- xs : List int
__lam_0 : (params (param "x" (tvar 23))) (fn (tvar 23) int-default)
Read __lam_0's signature. Its parameter type is tvar 23 — which is
map-list's own type variable a, unresolved. That looks like a
compiler bug and it is not one. A lambda written inline gets lifted into
a top-level definition, and the driver runs that lift after the resolve
pass. So a lifted lambda carries the types it was handed at the call
site, not the types resolution later worked out.
This is not folklore. The C# plug states it outright, in prose, above the function that handles it:
A lifted lambda reaches this plug with UNRESOLVED type variables in its signature: the compiler's IR-CCE lift runs after the resolve pass, so a
__lam_Ndef carries the expected types its lambda was handed, not the resolved ones.
C# answers dynamic and moves on. Zig has no dynamic, so the zig plug
has to actually recover the type — and it has machinery for exactly that.
It walks each of the callee's declared parameter types against the type
actually supplied, in step, and answers with whatever sits where the
variable sits. Declared List a against actual List Integer answers
a = Integer. If a pair doesn't mention the variable, it tries the next
argument.
"This pair doesn't mention it" was signalled by returning the empty string.
Now match map-list's declared first parameter, (a -> b), against
__lam_0's actual type, (tvar 23 -> Integer).
The walk finds a in the declared position. It reads what is sitting in
the corresponding actual position. That is tvar 23. It renders it, gets
the string "T23", and returns it — and "T23" is not the empty
string. So the scan stops, satisfied, believing it has resolved a.
It never reaches argument two, xs, whose declared List a against actual
List Integer is the answer that was wanted and was sitting right there.
The emitted zig puts the two rows side by side, and I don't think the defect has ever been easier to see than this:
fn mapped_named(xs: *CxList(i64)) *CxList(i64) {
return map_list(i64, i64, ... p0: i64 ... return bump(p0); ...);
}
fn mapped_lambda(xs: *CxList(i64)) *CxList(i64) {
return map_list(T23, i64, ... p0: T23 ... return __lam_0(p0); ...);
}
Same function, same list, same element type. One of them names a type that does not exist.
A recovered answer that is itself a variable is not a resolution. That
sentence is the entire fix. Concrete beats variable; variable beats
nothing. A variable answer is kept as a last resort rather than refused,
because inside a generic definition it is the correct answer — map-list's
own body calls map-list-loop, and there T23 really is a comptime
parameter in scope.
The part I find most instructive is that this plug had already built a detector for this exact gap, and the detector could not fire.
The recovery function ends in a deliberate @compileError("zig plug:
unresolved type variable T<id> of <callee>"), and there is a paragraph
above it explaining, at length, why a named marker is better than the
anyopaque it replaced — that ten silent failures read as one mystery
rather than ten facts.
That marker sits on the "no answer" path. A variable answer looked like success, so the code that reaches the marker was never executed. A gap detector that a plausible wrong answer walks past is not a detector. The failure isn't that nobody thought about this; someone thought about it carefully enough to write a good error message for it.
There were also two copies of the walk — one answering text for the type
argument list, one answering a type for substitution — with two different
sentinels, "" and VoidTy. The prose above them claimed "the walk is
shared and only the answer differs." It wasn't shared. It was copied. I
wrote the fix twice before collapsing them into the one thing the comment
already claimed they were.
And there was a second half from the same root. With a recovered, the
closure the plug builds around a function value still carried the variable,
because it read the lambda's type without passing it through the enclosing
call's own bindings. And since __lam_0 really is emitted generic —
fn __lam_0(comptime T23: type, x: T23) i64 — it was being called as
__lam_0(p0): one argument against two. The trampoline is a call site like
any other and had never learned to apply a generic callee.
The plug's own notes predicted this, six sites ago: "A GENERIC NAME MUST BE APPLIED, and nothing enforces it centrally — six sites had to learn independently... Expect a seventh." This was the seventh.
Latent, and the evidence is not circumstantial.
The defective code is byte-identical to what shipped in Update 44. The
recovery walk and the comptime-parameter machinery both entered the tree at
ff1d299b, six Updates ago, when the zig plug first landed. Here is the
line as it was written then, and as it still was this morning:
is TypeVar (vid) -> if vid == id then emit-zig-type actual else ""
Unchanged. The accept rule beneath it — if text-length found > 0 then
found — likewise unchanged. Nothing in Update 50 touched it, and neither
did any of the eight PRs we landed into Update 50.
The trigger is unchanged too. The whole of codex/compiler/IR/ is
byte-identical between the Update 49 pin and the Update 50 pin — an empty
diff, not a small one. ListUtils.codex, which defines map-list, is
byte-identical. The five comprehension sites in Lowering.codex whose
lifted lambdas produce the failing calls exist, unchanged, at both pins. So
neither the defect nor the shape that trips it is new.
But something DID change, and it is upstream and exact. Update 50 edited
opening.codex's source-emitting path:
- else let raw-defs = fe.ir.defs
+ in let lifted-ir = deck-record (lift-lambdas (fe.ir) lift-ceiling)
+ in let raw-defs = lifted-ir.defs
- let ir = ir-prune-unreachable-roots (fe.ir) ir-emit-roots
+ let ir = ir-prune-unreachable-roots lifted-ir ir-emit-roots
The file goes from one lift-lambdas call to two, and the new one is on the
path that feeds text plugs. Before Update 50, a source-emitting plug never
received a lifted lambda at all. The release commit message says it in
four words if you know to look: "the DDC witness holds after the lambda-lift
break."
So the shape of it is: a defect written in Update 44, sitting on a code path that no input could reach, until Update 50 started producing that input.
The artifacts agree, and this is the measurement I would keep if I could keep
only one. Last night's codexzig.zig — the build that reached a fixed point,
written four minutes past midnight — against today's:
last night today
lifted lambda defs 0 300
generic defs 18 90
map_list call sites 106 106
106 generic call sites, exercised, correct, both times. The recovery walk ran on every one of them and got the right answer every time, because every mapped function had a NAME. Last night's testing was not shallow and it was not lucky. It could not have found this, because the compiler was not yet capable of handing it the input.
That is a more useful answer than "we finally looked." We had looked, hard,
with a good instrument. What we could not do was manufacture a __lam_N,
because at that point nothing in the toolchain produced one for a plug.
Update 50 turning on the lift has a second consequence we had not noticed, and it is the reason the gate is still red after the fix.
codexzig checks itself by emitting its own bundle two ways and demanding
the results be identical: once through seed-plus-ring-plug, and once as a
single native program. Those two arms do not run the same pipeline. The
first goes through the driver. The second goes through our harness —
CodexZigHarness.codex — and our harness has zero lift-lambdas calls,
because when it was written the driver's text-plug path had none either.
It does now. So the two arms emit different programs: 300 lifted lambdas on one side, none on the other. The fixed point fails, and it fails for a reason that has nothing to do with the fix above:
BUILD: ... return __lam_0(p0, p1); ...
SELF : ... return emit_negate(_lam3_s, cx_list_at(_lam3_a, 0)); ...
The fixed point held last night because neither arm lifted anything. It has never been tested against a subject containing a lifted lambda, and the first time it was, it failed. That is a weaker property than "codexzig reaches a fixed point" sounded like, and the weakness was invisible for exactly as long as the two arms happened to agree by both being incomplete in the same way.
The same drift explains the blind spot: native/codexir runs that harness
too, so the corpus runner and the whole tier set cannot produce a __lam_N
either. They are not wrong. They are copies of a pipeline that moved.
This is the second copied-thing-that-fell-behind in one day. The first was
ir-emit-roots — four entries where upstream has six, in both harnesses,
found by a cold read that morning. This one is the same failure one level
up: not a copied list, a copied pipeline.
While writing this I went looking for whether the corpus had ever shown the
same symptom, and it had: two programs, typeclass-poly and
typeclass-smoke, refusing on use of undeclared identifier 'T16'.
That entry is in our own priority queue, with a parenthetical:
use of undeclared identifier 'T16'(a 16-element tuple;Tupledefines Tup2..Tup5 only)
T16 is not a 16-element tuple. T followed by an integer is precisely
how this emitter renders a type variable; it is the same string as the
T38 that stopped the release. Somebody read a plausible meaning into it,
wrote the plausible meaning down, and the queue has carried that reading
ever since — filed as a missing tuple arity, which is a boring gap nobody
would prioritise, rather than as an escaping type variable, which is the
defect that later stopped an Update.
I rebuilt the natives with my fix and re-ran both programs. They fail exactly as before, same errors, same count. So it is the same symptom class by a different route — those come from typeclass dictionary construction, not from a call to a generic definition — and it is still open. My fix does not touch them and I am not going to claim it does.
But it means the symptom class had been sitting in our own corpus, in our
own queue, for weeks, under a reading that made it uninteresting. That route
needed no Update 50 and no lambda lift. It needed someone to read T16 as
what it says instead of what it looks like.
What is fixed, and what it was measured against. The reproducer goes
red to green and matches bare metal. native_build.sh builds codexir
again: 47 errors to zero. The IR feeding that build is 8,669,320 bytes —
byte-for-byte the size it was in the failing run, which is how you know
nothing above the emitter moved.
A six-leg verification chain is running as I write this: the gate itself, the full sweep any emitter change owes, the tier set, and the corpus census. The census is expected to move, because the unresolved-type- variable marker now fires where a variable answer used to pass silently. That is the detector working for the first time, and it should be read as a measurement rather than as a regression.
And one blind spot worth more than the fix. lift-lambdas belongs to
the driver, not to the IR pipeline. So native/codexir — the fast native
front end everything cheap is built on — never produces a __lam_N at all.
corpus_run.py, tier_run.py --zig, and with them the entire tier set are
structurally incapable of posing this question, and all of them stayed
green through the whole failure. The tiers were not wrong. They were
answering a question that could not contain the defect.
That is the thing to keep. Not the sentinel bug — sentinel bugs are forever. The lesson is that a green suite is a statement about the questions the instrument can ask, and it is worth knowing, in advance and in writing, which questions each instrument cannot.