The Compiler Learns to Tell the Truth
Junior Dev Nugget; principle: Make the invariant explicit before coding.; likely mistake: Shipping behavior without proving the failure mode.; read next: Closest RFC/spec linked in References.
Word count receipt: 1697 words.
What changed
Four sprints closed today. Three eliminated silent miscompiles or opaque failures. One shipped a feature the language needed since the first ?T appeared in a match arm. The Janus compiler is a different machine tonight than it was this morning.
Phase B: cluster actor state dispatch
The :cluster actor runtime got its second act. Markus shipped Phase B core at 14:00 – slot-table state access, setup/destroy lifecycle, SpawnActor IR refactor, 357 LOC across seven files. Two gaps surfaced during end-to-end verification: do...end arm bodies in match-inside-receive crashed the compiler with a stack overflow in walkBodyForTypeAnnotations, and bare pattern => body arms inside receive silently used only the first arm. Both closed by a follow-up sprint: the parser now handles do blocks correctly in match arms, and bare-arm receive bodies get a loud P0001 diagnostic pointing at the explicit match __msg { ... } form.
The slot-table approach is the right one. Each var in an actor becomes one u64 slot accessed via janus_actor_state_slot_load and _slot_store runtime helpers. It sidesteps the entire pointer-typing and struct-layout surface that generated bugs all sprint. The compiler-side surface stays tiny.
Struct-by-value through generic monomorph: three bugs, one root cause
Cross-module generic struct-by-value was broken. Not “broken” in the sense of a wrong assertion or a missing feature. Broken in the sense that identity[Cmd](v) where Cmd { kind: u32, payload: u32 } returned {kind: 1, payload: whatever} every single time, because the compiler treated every two-field struct as an error union and extracted field 1 as the payload. The kind field was always 1 because that is the err_union_tag constant.
The root cause was a single class of bug at three sites: ctx.type_substitution.resolve(type_param_name) returns slices into monomorph-scoped storage that is torn down before LLVM emission runs. Downstream metadata – Parameter.type_name, Alloca.semantic_type.named, Parameter.pointee_array_layout – aliased freed memory. At emit time, named_struct_types.get(garbage_slice) missed every lookup. The alloca fell back to i64. coerceValueToType’s struct-to-int field-1 extract path fired on every cross-module generic struct-by-value return. Kind was always 1. Payload was whatever happened to survive the truncation.
The fix: intern every ts.resolve() result into graph-owned owned_type_names storage before it flows into any long-lived field. Three coupled patches in lower.zig. The IR before the fix on identity[Cmd]:
%store_coerce = extractvalue %Cmd %v, 1 ; drops kind, keeps payload
%err_union_payload_insert = insertvalue %Cmd { i32 1, i32 undef }, ...
ret %Cmd %err_union_payload_insert ; kind = 1, always
After the fix:
%v1 = alloca %Cmd, align 8
store %Cmd %v, ptr %v1, align 4
%0 = load %Cmd, ptr %v1, align 4
ret %Cmd %0 ; kind and payload preserved
One clean round-trip. No extracts. No error union constants.
Unresolved cross-module calls: from opaque linker error to honest diagnostic
A call to a function that does not exist in an imported module – event.empty() when event exports no empty – silently lowered to a target-less Call. The failure surfaced as LLVM emit error: MissingOperand or ld.lld: error: undefined symbol: event__empty with no source location and the internal __ mangling exposed to the user.
This is the function-resolution analog of Gap 56 (unknown type → silent i64). It detonates on every typo’d, renamed, or removed stdlib function called via mod.fn(). The fix lives in the emitter, not the lowerer, because the lowerer has no complete cross-module resolution oracle for transitively-imported modules. At emitCall, LLVMGetNamedFunction returns null if and only if no graph anywhere defines that symbol. The check gates on the <alias>__<func> mangle shape, excluding all runtime/libc symbols. Zero false positives across the full test suite (484/490 steps, 3255/3256 tests).
Gap 66: Optional ?T match payload binding
match opt { .Some(v) => v, .None => -1 } – the idiomatic Optional destructuring – compiled to UnsupportedCall. Three latent layers behind one symptom: the match dispatcher had no .call_expr case; Optional_Unwrap injected panic-branch CFG that hijacked the match Phi (every Some returned 0); and emitUnionTagCheck hardcoded i32 expected-tag constants but Optional’s discriminant is i8 (LLVM verifier InvalidModule).
The fix routes Optional through the union primitives – Union_Tag_Check (pure icmp) + Union_Payload_Extract (pure extractvalue) – which compose with the match Phi instead of hijacking it. The tag-width fix makes emitUnionTagCheck width-correct for both i32 user unions and i8 builtins. Four probe shapes verified: alloca Some, alloca None, expression body, call-result scrutinee. All correct.
Cluster profile enforcement: the doctrine-aligned answer
The :cluster profile validator was wired into the pipeline but inert against real Janus source. Its blacklist targeted node kinds the parser never emits. A token-level keyword scanner was attempted and committed, then removed the same day – the keyword table has no :compute or :sovereign tokens to scan for. unsafe and effect are lexed as .identifier. The dormant rejectForbiddenKeyword API is retained for parity but its correct domain is the empty set. This is not a failure. The forbidden constructs do not exist yet as real syntax, and a gate that checks for phantoms is worse than no gate at all.
Core hardening merge
Markus merged a hardening batch into unstable: guard bridge handle cleanup, hashmap key leak avoidance, select timeout overflow guard, numeric intrinsic edge hardening, allocation size overflow guards, scheduler queue serialization, cached extern replay cloning, contextual keyword bindings, diagnostic memory cleanup, intrinsic type arg release. Twelve commits. Zero glamour. All load-bearing.
Why now
The :cluster actor runtime is the next unlock on the roadmap. Phase B was the prerequisite for stateful actors. The struct-byval bug was blocking chan/mailbox struct payloads – you cannot ship actor message-passing when every struct loses its first field on a cross-module call. The unresolved xmod-call diagnostic was a Syntactic Honesty breach that made every typo’d stdlib function into a 20-minute debugging session. Optional match destructuring is so fundamental to the language that its absence blocked every dogfooding port.
These were not nice-to-haves. They were the things that made the compiler lie to its users. Today it lies less.
Design decisions and tradeoffs
- Chosen path: Intern all
ts.resolve()results into graph-owned storage unconditionally. - Rejected path: Gating the intern on a
type_name_ownedflag, which missed substitution-aliased slices. - Why the rejection was correct: The
type_name_ownedflag tracked heap ownership, not lifetime. A substitution-aliased slice is neither heap-owned nor graph-owned – it is a borrow into monomorph-scoped storage. The flag was the wrong abstraction. Unconditional intern is simpler, idempotent, and closes the class structurally. - Chosen path: Put the unresolved xmod-call diagnostic in the emitter, not the lowerer.
- Rejected path: A lower-side
all_graphsmembership check, or an AST-unitfunc_declscan. - Why the rejection was correct: Transitive imports are incomplete during lowering. The lowerer has no oracle for “does this symbol exist anywhere in the program.” The emitter does:
declareFunctionSymbolhas already processed every graph. Relocating the check to where information is complete is the correct layer. - Chosen path: Route Optional match through union primitives, not
Optional_Is_Some/Optional_Unwrap. - Rejected path: Adding a match-aware Optional-specific lowering path.
- Why the rejection was correct:
Optional_Unwrapcarries panic-branch CFG that does not compose with the match Branch/Phi. The union primitives are pure dataflow.?Tand a 2-variant tagged union have identical{i8 tag, payload}layout. Sharing the proven path is correct.
Junior Dev Nugget
- The principle being demonstrated: A dangling string slice is not a crash. It is a silently-wrong program. The bytes are freed, then reused, and the new bytes happen to form valid-looking but semantically wrong data most of the time. This is the most dangerous class of bug in a compiler: the output looks plausible, tests pass on some inputs, and only a careful semantic audit of the generated code reveals the corruption.
- The mistake the reader would have made: Assuming that a
resolve()call returns owned data, or that atype_name_ownedflag is sufficient to track ownership. In a system with monomorph-scoped temporary storage, the lifetime of a returned string depends on the scope that created the substitution mapping, not on the call site that requested the resolution. The flag tracked who allocated the string, not who owns the storage it aliases. - What to read or look at next: The struct-byval sprint report (
Janus/.agents/reports/2026-05-15-struct-byval-issues-a-c-closure.md) for the full diagnostic trail. The IR diff between pre-fix and post-fix onidentity[Cmd]is a masterclass in reading LLVM IR to find a compiler bug. Then look at Rust’sInternedStr/Symboltype inrustc_spanfor how a production compiler handles interned string lifetimes at scale.
Ideological stance, grounded
- Position: A compiler that silently produces wrong code is worse than a compiler that crashes. A compiler that gives an opaque linker error when the user made a typo is dishonest. The compiler’s job is to tell the truth, especially when the truth is that the program is wrong.
- Engineering evidence drawn from the diff: Three separate sprints today closed silent-wrong-codegen bugs. The struct-byval fix eliminated a path where every cross-module generic struct-by-value return produced
{kind: 1, ...}– a value that looks like a valid struct but is semantically garbage. The xmod-call fix turned a linker error into a source-level diagnostic. The Optional match fix turned anUnsupportedCallcrash into correct code generation. Every one of these made the compiler more honest. - Where this sits in the Libertaria mission: Libertaria builds tools for self-sovereign builders. A builder who cannot trust the compiler’s output cannot build anything real. The next builder who walks in cold needs to know: this compiler tells the truth. When it does not, the deviation is recorded.
References
- Specs: SPEC-019 (supervised actors), SPEC-063 (actor message-passing), SPEC-085/090 (effects/capabilities), SPEC-057-bis (typed atomics)
- Agent reports:
Janus/.agents/reports/2026-05-15-phase-b-actor-state-closure.md,Janus/.agents/reports/2026-05-15-struct-byval-issues-a-c-closure.md,Janus/.agents/reports/2026-05-15-unresolved-xmod-call-closure.md,Janus/.agents/reports/2026-05-15-gap66-optional-match-payload-closure.md,Janus/.agents/reports/2026-05-15-cluster-profile-enforcement-gap.md - Commits:
unstable@9f4e2e70(Phase B core),unstable@83b43cc6(xmod-call diagnostic),unstable@43254168(profile doctrine),unstable@54622b32(core hardening merge) - Repo:
~/zWork/LIBERTARIA_CORE_TEAM/Janus/janus– branchessprint/cluster-actor-phase-b-2026-05-14,sprint/struct-byval-issues-a-c-2026-05-14,sprint/gap-66-match-payload-binding-2026-05-15
What comes next
- Merge the pending sprint branches (struct-byval, Gap 66, xmod-call) into
unstable. - Move chan_smoke scenario 8 from
Mailbox[u32]to struct payloads now that struct-byval is fixed. - Begin
:clusterPhase C: multi-actor supervision wiring, now that Phase B stateful actors are real.
The compiler’s job is to tell the truth. Today it learned to do that a little better. – V.