Spec M45 -- i32, and a call returns what it declares

Owner's ruling (2026-09-04, translated from the Portuguese): "A clear sign that not having a 32-bit primitive is a problem that needs correcting (implement a 32-bit signed integer primitive)." Read as: mc gains a signed 32-bit integer, i32 -- registered by the core itself through the M24 mechanism, not added to the ladder (see the Amendment below, which overrides § 1, § 2, D1, D2 and D12 of this draft) -- and, because the defect that surfaced it is about RETURN narrowing, a call's result takes the callee's DECLARED type, extended from the 32/16/8-bit register view by that type's signedness, for i32 and for the existing u8/u16/u32.

The defect, as M42 exposed it (docs/specs/M42.md § Implementation notes 10, branch m42-elf-exe, now merged): C's open returns an int; AAPCS64 and the SysV x86-64 ABI leave bits 63..32 of the result register UNSPECIFIED for a 32-bit return; mc declares extern i64 open(...) (lib/sys.mc:16, src/arena.mc:5) and reads all 64 bits, so i64 fd = open(...); if (fd < 0) is ALLOWED to be wrong for a -1. On re-measurement (ubuntu:latest and alpine:3, both architectures) every libc happened to hand back a fully sign-extended 0xffffffffffffffff, so the hazard is latent today, not observed -- and that is precisely why it must be fixed by construction rather than by trusting the libc of the day. The same holds on both Windows ABIs (BOOL/DWORD are 32-bit). Every C function returning int/unsigned/short/char on every target is trusted as a 64-bit value today.

Sequencing: after M41 (main at 611671a). The glibc proof in Acceptance 1 needs a dynamically linked Linux executable; see D14 for the two ways to get one (M42's --exe, or the distro linker inside the container). Line references are to main at 611671a; branch files are named with their branch.

What already exists #

Design #

1. The word: i32 is a core keyword appended at the end (no shift) #

tok_init gains one line after tok_add("=>", 2): tok_add("i32", 3) -- id K_I32 = 301 (src/lex.mc). K_U8..K_EXTERN stay 256..269; : and => stay 299/300. type_of_token gains else if (id == K_I32) t = TY_I32; (src/parse.mc:683-696), which is the one line M24 avoided for a REGISTERED type and is exactly right for a CORE one: the name is valid in all seven type positions (global, local, parameter, extern, cast, array element, p_type()) because they all end in that lookup. type_disable(TY_I32) works through the same function (M41), which is what the AVR compiler will call (§ 6).

Three guards learn the new id, +1 line each: word_add's "cannot redefine core keyword" (src/hooks.mc:235), the #rule dispatch-literal guard (src/parse.mc:1379) and the cannot redefine core keyword: if path type_new already takes through word_add. Written as id == K_I32 || (id >= K_U8 && id <= K_EXTERN), so nothing about the contiguous range moves.

What the frozen seed sees. stage0/lex.c lexes i32 as an identifier (T_IDENT, id 1); stage0/parse.c then fails at the first use with type expected (or type expected at top level). Consequences, stated exactly:

The alternatives are argued in D1.

2. The type: TY_I32 = 7, TY_MAX = 8, signed, width 4 #

src/ast.mc: #define TY_I32 7, #define TY_MAX 8, type_names[] gains "i32", type_width gains if (t == TY_I32) return 4;, type_align and type_kind need nothing (a core type aligns to its width and answers TK_INT). One new predicate, the only place signedness is written down from now on:

i64 type_signed(i64 t) { return t == TY_I64 || t == TY_I32; }

bin_op(op, type_signed(res_type(nd_a(n)))) (src/gen_walk.mc:622) and const_bin's two tests (src/parse.mc:928, :944) read it. res_binary's rule is unchanged: a binary keeps its LEFT operand's type, so i32 / i64 is sdiv, u32 / i32 is udiv, and a comparison is i64.

Because 7 was the first registered id, every type_new id shifts by one (ty_f64 7 -> 8, and so on). Measured: no file writes a registered id as a number (§ What already exists); lib/machine_probe.mc:53 keys on TY_MAX symbolically; ty_disable_set's mask reaches 62. The two documents that print "(7)" -- docs/reference/language.md:87, docs/guide/96-a-new-primitive.md:9 -- say 8.

Semantics, in the model u32 already has (§ 2 of docs/reference/language.md), so that one sentence covers the four narrow types:

u8 u16 u32 (today, unchanged)i32 (new)
width / align1 / 2 / 44 / 4
a load into a depth (MTASK_LOCAL_LOAD, MTASK_GLOBAL_LOAD, a parameter's re-read)zero-extends: ldrb/ldrh/ldr w; movzx/mov r32; lbu/lhu/lwusign-extends: ldrsw; movsxd; lw
a store (MTASK_*_STORE, MTASK_PARAM)truncates to the widthtruncates: str w / mov [m], r32 / sw -- the same instruction as u32
arithmetic64-bit on the extended value; wraps at the next store or castthe same: 64-bit on the sign-extended value
/ % >>unsignedsigned (sdiv/idiv/div, asr)
comparisonssigned on the 64-bit value (always were)signed on the 64-bit value -- correct, because the value is sign-extended
(T) x cast (MTASK_CAST)and #mask / mov wd, wnsxtw xd, wn / movsxd rd, rd / addiw rd, rd, 0
fold_cast of a constantmaskssign-extends from bit 31 (v & 0xffffffff, then - 0x100000000 if bit 31)
literalTY_I64 (no i32 literal; i32 x = -1 stores 0xffffffff and loads -1; i32 x = 0xffffffff does the same)
ld32(p) / st32(p, v)ld32 stays u32, zero-extending -- unchanged, inertthe signed read of memory is spelled (i32) ld32(p): one ldr w + one sxtw
u32 <-> i32a cast between them is a no-op ON THE STORED BYTES and on bits 31..0 of the depth; bits 63..32 of the depth differ ((i32) 0xffffffffu is -1, (u32) of an i32 -1 is 4294967295)
#definefolds in 64 bits; #define M (i32) 0x80000000 is -2147483648

The one observable difference from C, stated once: INT_MIN / -1. With i32 a = -2147483648; i32 b = -1; i64 q = a / b; the division is sdiv x on sign-extended operands and q is 2147483648 on every machine -- no trap on x86-64 (a 32-bit idiv would #DE), and INT_MIN again only after a store to an i32. That is the price of "64-bit on the extended value", the model u32 has had since M0 (u32 a = 0xffffffff; i64 x = a + 1 is 4294967296), and it is what keeps fold() and the runtime in agreement (docs/reference/language.md:160-166). True 32-bit operations are D3's alternative.

Printing negatives: lib/io.mc's putnum stays non-negative and lib/io.mc is untouched (D11) -- any change to it moves every test object under check-inert in the mechanism commit. The i32 tests carry their own six-line puti(i64); if the owner wants putint in <sys>, it lands in the declarations commit (§ 7), never in the first.

3. Narrow returns: the walker extends through the existing MTASK_CAST #

No new slot. gen_call (src/gen_walk.mc:762-772) becomes:

    callp(mach(MTASK_CALL), depth, i, sym_ref(usym(fs_name(fs_at(fi)))));
    i64 rt = res_type(n);
    if (walk_narrow(rt)) {                       // M45: what the callee declared
        set_walk_depth_type(depth, rt);          // depth d now holds the RESULT, not argument 0
        callp(mach(MTASK_CAST), rt, depth);
    }

with, beside walk_word():

// M45: a core type narrower than the word. Its value at a depth is DEFINED by
// extension from its width -- zero for u8/u16/u32, sign for i32 -- and the
// walker performs that extension wherever a foreign register hands it a bare
// 32-bit view: after a call, and before a return. A registered type is not
// narrow in this sense: what its value looks like at a depth is the module's
// (M24's rule), and <float>'s MTASK_CALL already returns f32 from s0 itself.
i64 walk_narrow(i64 t) { return t > TY_VOID && t < TY_MAX && type_width(t) < 8; }

The set_walk_depth_type line is load-bearing: after MTASK_CALL, dtype[d] still holds ARGUMENT 0's type (gen_value(a, depth + i) wrote it), and lib/machine_arm64_float.mc's fa_cast (:378-382) reads walk_depth_type(d) as the SOURCE type -- for extern i32 f(f64 x) it would otherwise see f64 and emit fcvtzs. With the line, fa_cast sees int -> int and delegates to the pristine a64_cast, which is sxtw.

The callee side, for symmetry (D5). N_RETURN (src/gen_walk.mc:904-908) issues MTASK_CAST(fnret, 0) before MTASK_RET(0) when the FUNCTION's declared type is narrow (walk_fn_ret, a global gen_func sets from nd_type(f); +4 lines). So an mc function declared u8 low(i64 x) { return x; } hands back 44 for 300 in a fully extended x0/rax/a0, which is what a C caller compiled by clang expects of a narrow result (its de-facto x86-64 convention is "extended to 32 bits by the callee") and is valid under every ABI. A function without an explicit return is untouched, so the #opcode syscall wrappers that rely on "the epilogue leaves x0 alone" (docs/reference/objects.md:260-265) still return the kernel's raw value.

Why arguments were never the problem, and why C-to-mc calls now are fixed too. mc -> C: every argument is a depth, and a depth is a 64-bit register holding a value already extended by the load or literal that produced it; a C int parameter reads bits 31..0 of it, which are right, and Apple's stricter rule (the caller extends narrow arguments to 32 bits) is satisfied for free. C -> mc (a callback): a C int argument arrives with bits 63..32 unspecified; an mc parameter declared i32 is stored at 4 bytes by MTASK_PARAM (str w, src/machine_arm64.mc:296-300) and re-read with ldrsw, so the garbage never reaches a depth. An i64 parameter fed a C int stays wrong -- declare it.

What does not change. callp keeps TY_I64 (src/gen_resolve.mc:442-448): a pointer call has no declaration, and D6 keeps it that way (a caller that knows writes (i32) callp(...)). Core intrinsics are not calls (gen_intrin, :660-667; ld8/16/32 are zero-extending by construction and their types unchanged). Taught intrinsics run the module's handler instead of the call sequence (lower_uintrin), and their result convention is the module's. A callee declared i64/u64/uptr/void or of a registered type issues no cast.

Inertness, exactly. The corpus has no narrow-declared callee and no narrow-declared mc function IN ITS SOURCE TEXT (§ What already exists), so the mechanism commit produces byte-identical objects for all 32 tests/*.mc, src/mc.mc and tests/float/* (registered returns are excluded by walk_narrow). The grep that established that is over source text, and a Tier 3 module can declare a narrow function without writing one: examples/api's class handler builds u8 todo_done(uptr self) out of AST nodes for the field bool done; (bool is type_alias("bool", TY_U8)), which no grep over examples/ can see. That example is therefore NOT inert, by design and harmlessly -- see § Implementation notes 3, which measures it. The bytes that WILL change, later and by design, are: scripts/check-surface.sh's decl_find-widen heredoc (u8 low: one and x9, x9, #0xff on each side; the answer stays 42 and the case asserts the exit code, not bytes), and every source whose declarations § 5 changes.

4. The machines: two bundled, two taught -- contract version 4, no slot added #

The ty a task receives may now be TY_I32, which every ty-carrying slot must honour (PARAM, CAST, LOAD, STORE, LOCAL_LOAD/STORE, GLOBAL_LOAD/STORE). That widens "what a machine outside src/ may rely on" without moving a signature, which is the same kind of bump as 2 -> 3 (docs/reference/machine.md:24-29); the page becomes contract version 4 and says: "a machine that does not implement TY_I32 must type_disable(TY_I32) from its user_init, or its MTASK_CAST is silently a no-op and its MTASK_LOCAL_LOAD silently reads eight bytes" -- which is precisely what rv_cast (:363-375) and rv_mem_op (:188-192) would do today.

machineload of an i32 slotcast to i32lines
src/machine_arm64.mcI_LDRSW (0xB9800000, scale 4) appended to the mem_* tables; mem_op(TY_I32, 0) -> it, mem_op(TY_I32, 1) -> I_STRW; d_mem prints an x register for itI_SXTW (0x93407C00 | rn<<5 | rd), one line each in gen_cast, dump_ins, encode~14
src/machine_x86_64.mcX_LDS32: XF_LD, w=1, opc 0x63 (movsxd r64, [m]), a x86_desc row and a name; x86_mem_op(TY_I32, 0)X_MOVSXD: XF_RD, w=1, opc 0x63; one line in x86_cast~10
lib/backend_arm64.mc (the surface encoder check-surface compares 32/32)the same two rows in sur_mem_*one line for I_SXTW~4
examples/kernel/machine_riscv64.mclw (opcode 0x03, funct3 010) in rv_mem_opaddiw rd, rd, 0 (sext.w) in rv_cast~8
examples/avr/machine_avr.mc (branch m40-avr)-- its invariant "every core type but i64 is unsigned, so extend always means zero" (:20-27) is false for i32type_disable(TY_I32) in mc-avr.mc's user_init, plus a README sentence; sign-fill is a later ask~3

lib/machine_arm64_float.mc / lib/machine_x86_64_float.mc need nothing: fa_cast/fx_cast delegate an int-to-int cast to the pristine table (:378, :438), their MTASK_CALL reads walk_ret_type() only to pick the float file, and fa_is_float keys on type_kind (:180) -- unaffected, confirmed. lib/machine_probe.mc asserts t < type_count() and t < TY_MAX for an untaught program; both hold.

Every new instruction goes through the llvm-mc sweep that is mandatory for a machine in lib/ (CLAUDE.md § M24 step 1): ldrsw/sxtw (mach-o and elf aarch64), movsxd register and memory forms (elf and coff x86-64), lw/addiw (riscv64).

5. The host layers and the libraries: truthful declarations, one seed-safe helper #

Per function, what C returns and what the declaration becomes. Two rules decide the spelling: a source the seed compiles (src/*.mc, lib/sys.mc, lib/sys_windows.mc, lib/io.mc) may say u32 -- the truthful WIDTH the seed knows -- and reads the sign through a helper; a source only mc1 or a taught compiler compiles says i32.

src/arena.mc gains the helper, pure arithmetic, correct under every seed and every machine:

// M45: the value of a C `int` result. bits 63..32 of a narrow return are
// unspecified on every ABI this compiler targets; this file is compiled by
// the frozen seed, which cannot spell `i32`, so the declarations below say
// `u32` (the width) and the sign comes from here.
i64 c_int(i64 v) { v = v & 0xffffffff; if (v & 0x80000000) return v - 0x100000000; return v; }
filedeclarations that changereads that change
src/arena.mc:5-8, :17open, creat, close -> u32 (C int); read/write stay i64 (ssize_t); mmap stays uptr; _exit voidi64 fd = c_int(open(...)) (:539), c_int(creat(...)) (:557)
src/sysroot.mc:58--c_int(open(...))
src/host_macos.mc:35-41, src/host_linux.mc:17-23, src/host_windows.mc:27-33posix_spawnp, posix_spawn_file_actions_init/addopen/destroy, waitpid (pid_t), mkdir, unlink -> u32; _NSGetEnviron stays uptrsrc/driver.mc:229, :242: if (c_int(waitpid(...)) < 0) -- the same latent defect as open, today undetectable on glibc; posix_spawnp's e == ENOENT / != 0 tests are already right on a zero-extended value
src/backend_exe.mc:34chmod -> u32 (result unused)--
src/host_windows.mc:65GetEnvironmentVariableA -> u32 (DWORD)n <= 0 becomes n == 0
lib/sys_windows.mc:44-51, lib/sys_windows_host.mc:39-47WriteFile, ReadFile, CloseHandle, CreateProcessA, WaitForSingleObject, GetExitCodeProcess, CreateDirectoryA, DeleteFileA, GetLastError -> u32 (BOOL/DWORD); handles/pointers stay uptrthe & BOOL_MASK masks stay (D9): the Windows chain's seed is a published release that does not narrow, and the masks are what keeps mc1w right under it
lib/sys.mc:16-21, :46-51 (macOS programs; ten seed tests include it)decided by measurement (Acceptance 1): if libSystem's cerror writes x0 as a 64-bit -1, the declarations stay i64 with the measurement recorded in the header and a sentence in docs/reference/language.md § extern that a program wanting the truthful declaration writes its own extern i32 open; if it does not, they become u32 and c_int moves to lib/io.mc in the declarations commit
lib/sys_linux.mcnothing: raw svc wrappers, the kernel returns 64-bit -errno
examples/api/lib/sqlite.mc:26-38sqlite3_open/close/exec/prepare_v2/step/finalize/bind_int/bind_text/column_int/changes -> i32; sqlite3_last_insert_rowid stays i64 (sqlite3_int64); errmsg/column_text stay uptr; sqlite3_libversion_number (test_sqlite_lib.mc) -> i32rc != SQLITE_OK-style tests were right only because compilers write w0; sqlite3_column_int of a NEGATIVE column was wrong
examples/api/lib/http.mc:25-29socket, setsockopt, bind, listen, accept -> i32http_listen's three < 0 tests (:55-66) and main.mc:336's cfd < 0 -- the same defect class as open
examples/api/tests/lib_test.mc:17unlink -> i32
examples/conc/lib/{macos,linux}/thread.mcpthread_create/join/detach/mutex_*/cond_*, sem_*, sysctlbyname -> i32; dispatch_semaphore_wait/signal (intptr_t) and getauxval (unsigned long) stay i64
examples/desktop/lib/gtk.mc:52, :92g_application_run, gtk_check_button_get_active -> i32; the two (u32) casts (main.mc:262, gtk.mc:111) go

Bootstrap consequences. Both commits touch src/, so the five goldens (tests/golden/mc2*.sha256) are rewritten once per commit, after an empty mc1/mc2 --dump-asm diff and cmp mc2.o mc3.o (M41's rule, Implementation note 9). The Linux and Windows chains from a PUBLISHED seed keep working: the seed compiles extern u32 open (a word it knows), does not narrow, and c_int makes mc1l/mc1w right anyway; mc1l then narrows when it builds mc2l, and mc2l == mc3l holds because both are built by narrowing compilers. Windows: kernel32 results are BOOL/DWORD, 32-bit; u32 is exactly their width and the masks keep the released seed honest.

6. Tests and gates #

Out of scope #

Files and estimated deltas #

Commit 1 -- the mechanism (inert):

filelineswhat
src/lex.mc+2K_I32 301; tok_add("i32", 3) after =>
src/ast.mc+8/-1TY_I32 7, TY_MAX 8, "i32" in type_names, type_width, type_signed
src/parse.mc+6type_of_token; const_bin via type_signed (2 edits); fold_cast sign-extension; the #rule guard
src/hooks.mc+1word_add refuses K_I32
src/gen_walk.mc+30 (12 code)walk_narrow, walk_fn_ret, the cast after MTASK_CALL, the cast before MTASK_RET, bin_op via type_signed
src/gen_resolve.mc+3 (comment)res_call already answers the declared type; say so
src/machine_arm64.mc+14I_LDRSW, I_SXTW: tables, mem_op, gen_cast, dump, encode
src/machine_x86_64.mc+10X_LDS32, X_MOVSXD: two x86_desc rows, names, x86_mem_op, x86_cast
lib/backend_arm64.mc+4the two encodings
examples/kernel/machine_riscv64.mc+8lw in rv_mem_op, addiw in rv_cast
examples/avr/mc-avr.mc, README.md (branch m40-avr)+4type_disable(TY_I32) and why
tests/mc/090..093-i32-*.mc~160new
tests/linux/072-int-return.mc, tests/windows/073-int-return.mc~50new
scripts/test-linux.sh, scripts/test-windows.sh+12/-14 eachthe tests/mc/0[89]*.mc loop; a glibc image leg for tests/linux/072 (D14)
scripts/check-surface.sh+20the sxtw/and/no-cast abi_case
scripts/sysroot-windows.sh+1GetFileAttributesA
src/bundle_data.mc, five goldensregenerated
docs/reference/language.md+45§ 2 an eighth word and the extension table; § 3 the INT_MIN / -1 sentence; § 6 "a call returns what it declares", callp excepted
docs/reference/machine.md+35version 4 header; MTASK_CAST "extend by the type's signedness"; the walker's cast after CALL and before RET; the i32 row per machine; the type_disable obligation
docs/reference/objects.md+20§ 4/4b/4c: what a narrow result guarantees on each side
docs/core-language.md:26, docs/plan.md:79, docs/reference/hooks.md, docs/guide/96, docs/reference/diagnostics.md+12"no i8/i16", TY_MAX 8, K_I32 refused by word_add/#rule
CLAUDE.md, docs/plan.md+30the M45 entry and row

Commit 2 -- the declarations (bytes move by design):

filelineswhat
src/arena.mc+6c_int; open/creat/close as u32; two reads
src/host_macos.mc, src/host_linux.mc, src/host_windows.mc~7 eachu32 declarations
src/driver.mc, src/sysroot.mc, src/backend_exe.mc+4c_int at the two waitpid reads and the open
lib/sys_windows.mc, lib/sys_windows_host.mc~9 eachu32 declarations; masks kept
lib/sys.mc0 or ~8per Acceptance 1's macOS measurement
examples/api/lib/{sqlite,http}.mc, tests/lib_test.mc, test_sqlite_lib.mc~18i32 declarations
examples/conc/lib/{macos,linux}/thread.mc~30i32 declarations
examples/desktop/lib/gtk.mc, main.mc+2/-2i32 declarations, casts dropped
five goldensregenerated again

Net new src/ lines in commit 1: ~75, of which ~40 are code. stage0/: 0.

Acceptance #

  1. The defect is measured before anything changes, with the pre-milestone build/mc1, and the numbers go into this spec's Implementation notes: the extern i64 open program printing the raw result in hex on (a) glibc aarch64 and x86_64 in ubuntu:latest, expected ffffffff and the fd < 0 branch NOT taken (the failing case); (b) musl in alpine:3, both architectures (whatever it prints -- the claim "musl zero-extends by accident" is UNVERIFIED and is replaced by the measurement); (c) macOS libSystem (open, close(-1), waitpid(-1)) -- unverified today, and it decides lib/sys.mc's row in § 5; (d) Windows: GetFileAttributesA of a missing path through the pre-milestone compiler, on the windows-2025 legs.
  2. Commit 1 is inert. scripts/check-inert.sh between the pre-milestone build/mc1 and the mechanism commit: identical for all 32 tests/*.mc, src/mc.mc, and the five taught examples (kernel included, with its updated machine). check-obj 32/32 against the frozen seed; check-lex/check-ast/check-asm at their current counts with no new skip; check-float 12/12 on all five legs and check-wide unchanged (registered returns excluded); check-surface 32/32 objects identical plus the new abi_case; bootstrap at a fixed point; the five goldens rewritten once.
  3. i32 round-trips on all five targets. tests/mc/090..093 green through check-mc (macOS, object and --exe), test-linux (aarch64), test-linux-x86_64, test-windows (arm64) and test-windows-x86_64. 092 prints 2147483648 for INT_MIN / -1 on every one of them, with no SIGFPE on x86-64.
  4. The narrow-return proof, on every host with a libc. tests/linux/072-int-return.mc fails through the pre-milestone compiler under glibc (open=4294967295, exit 1) and passes through the new one (open=-1, exit 0) on both architectures; passes under musl on both; tests/mc/093 passes on macOS (close(-1) is -1, neg() is -1, low(300) is 44); tests/windows/073 passes on both Windows legs.
  5. The sweep. Every distinct instruction the two bundled machines emit over tests/mc/09* and src/mc.mc re-assembles under llvm-mc byte for byte: ldrsw/sxtw on mach-o and elf aarch64, both movsxd forms on elf and coff x86-64; lw/addiw on riscv64 through examples/kernel/test.sh's sweep.
  6. The taught machines' gates stay green: check-kernel (image bytes unchanged from Acceptance 2), and check-avr on m40-avr after the merge, where a source spelling i32 is refused with i32: removed by this compiler.
  7. Commit 2 fixes the compiler itself. mc-linux-aarch64 built for glibc (D14) exits 1 with cannot open on a missing input instead of reading a bogus descriptor; check-obj still 32/32 (both compilers see the same changed sources); bootstrap at a fixed point; make check-linux-host and the Windows CI chain reach their fixed points from the CURRENT published seed; examples/api, conc, desktop and check-examples/check-conc/check-desktop green with the truthful declarations; goldens rewritten once more.
  8. Docs. check-docs green: type_signed, walk_narrow, c_int documented; every mc sample in docs/reference/language.md § 2's new table compiles and runs; site/check-site green.
  9. git diff --stat stage0/ is empty, and no file in the seed set gained a seed-skip header.

Risks #

  1. The parity gates, if i32 leaks into the seed set. One i32 in lib/sys.mc breaks check-lex/check-ast/check-asm and, through the ten tests that include it, check-obj. Acceptance 9 is the guard; § 5's spelling rule is the discipline.
  2. Registered ids shift by one. Measured: no numeric dependence anywhere; type_count(), TY_MAX and module globals carry every id. The two "(7)" sentences in the docs are the whole cost. A module outside this tree that wrote 8 for its second type would break, and docs/reference/hooks.md should say the number is not surface.
  3. The three taught machines' silent no-op. A machine that predates TY_I32 does nothing on MTASK_CAST(TY_I32) and reads eight bytes on MTASK_LOCAL_LOAD(TY_I32). The riscv64 machine gets its arms here; AVR disables the word; the contract page makes the obligation explicit. lib/machine_probe.mc could additionally refuse TY_I32 on a machine derived from a pristine table it did not extend -- optional.
  4. walk_depth_type after a call. Without the set_walk_depth_type(depth, rt) line, the float-derived machines would treat an i32 result as argument 0's type. It is one line and the extern i32 f(f64) shape belongs in tests/float/ as a regression (022-int-return.mc, +25 lines, on the five float legs).
  5. The u32-declared functions whose bytes change. None in the corpus today; after commit 2, every host layer and examples/api/conc/desktop. Those changes are the FIX, verified by Acceptance 4 and 7 -- but they also mean commit 2 cannot be proved inert and must not be folded into commit 1.
  6. examples/api's sqlite wrappers return i32 values into i64 locals everywhere; nothing breaks, but sqlite3_column_int of a negative value silently changes from a large positive to the negative it always was in C. Say so in examples/api/README.md.
  7. The seed-skip headers: the milestone adds none; the two existing ones stay. If the owner later re-seeds stage0/, every u32+c_int in § 5 becomes a mechanical i32, and this spec's § 5 table is the list.
  8. MAXDEPTH, spill, frame: unchanged. An i32 depth is an 8-byte slot holding the sign-extended value; an i32 local is a 4-byte store in an 8-byte-granule slot (slot_new rounds), byte-identical in layout to a u32 local.
  9. Interaction with <float>: fa_is_float keys on type_kind and TY_I32 answers TK_INT; fa_cast/fx_cast delegate int-to-int; MTASK_CALL in both float machines reads walk_ret_type only for the float file. Confirmed by reading; Acceptance 2's check-float on five legs is the proof.
  10. The glibc gate's dependency (D14): if the proof is linked with M42's --exe, M45 sequences after m42-elf-exe; if with the distro linker inside the container, it is independent but slower (apt-get in the run, or a prepared image).

Decisions (architect) -- to ratify with the owner #

Amendment (owner, 2026-09-04): i32 through the M24 mechanism, signedness by kind #

Owner's ruling (translated from the Portuguese): "Touching stage0 is out of the question. Since we have u32, I do not believe applying an i32 is that hard; we have already proved that new primitives can come from the developer, so just do the same from src/."

This overrides D1 (a core keyword K_I32 = 301 appended to tok_init) and D2 (TY_I32 = 7, TY_MAX = 8) in favour of the alternative D1 rejected as (a): the core registers i32 at init through type_new, before user_init(), with an id at or above TY_MAX, in the same alias table type_new already writes. Sections 1, 2 and 4 of the draft are replaced by A1-A3 below; A4 says what survives and how it is renumbered; A5 restates the decisions to ratify. The draft's own objection to (a) -- "every core decision would have to be re-taken for a registered type that is secretly core" -- is answered in A2, one site at a time. Nothing in stage0/ moves under either design; the ruling picks the mechanism the tree already proved over a second way of introducing a word.

A1. The word and the id: one type_new call in src/, and a new kind (replaces § 1 and § 2) #

Registration. src/hooks.mc gains, beside type_new (src/hooks.mc:490-497):

// M45: the core's own registered primitives. The same call a module makes
// (lib/float.mc:420), made from src/ before user_init(), so the word enters
// the alias table type_of_token already consults and the id is at or above
// TY_MAX. A recreated compiler that cannot honour it says type_disable(ty_i32).
i64 ty_i32 = 0;
void core_types_init() { ty_i32 = type_new("i32", 4, 4, TK_SINT); }

called from src/cli.mc immediately before user_init() (src/cli.mc:230): after tok_init() (:215), so K_U8..K_EXTERN are fixed at 256..269 -- the comment at :226-229 is exactly the constraint -- and before the first token is read. ty_i32 is a global the core exports, the lib/float.mc:30-32 convention; it is never written as a number.

What that one call does, by the code that already exists: ty_reg_add (src/ast.mc:101-115) appends the registry row and returns TY_MAX + 0 = 7; type_name(7) is "i32" (:255-257, so --dump-ast and i32: removed by this compiler both print it); type_width 4, type_align 4 (:289-303); type_kind TK_SINT (:307-310); alias_add (src/hooks.mc:456-463) reserves the word through word_add (:237-242) -> tok_add (src/lex.mc:207-223), and type_of_token's last arm, alias_find(id) (src/parse.mc:713), answers it in all seven type positions. Zero lines in tok_init, type_of_token, word_add's guard, the #rule guard (src/parse.mc:1411) or type_names[]. D12 (three guard lines for K_I32) is withdrawn: the word is protected the way every registered word is.

Who does not register it. src/lexdump.mc includes only arena.mc and lex.mc (:4-5) and calls only tok_init() (:13): it lexes i32 as an identifier, id 1, exactly as stage0/lex.c does, so check-lex parity over the seed set is structural under this design where the keyword design had lexdump answering 301 against mc0's 1. src/astdump.mc states its purpose at :13 ("this driver does not call user_init, so parsing is the same as mc0's") and keeps it: it does not call core_types_init either, so its answer to a file spelling i32 is the seed's (type expected). One call site, src/cli.mc.

What the frozen seed sees: unchanged from § 1. stage0/parse.c fails at the first use with type expected; a seed-set file that spells i32 breaks check-ast/check-asm/check-obj and must carry // seed-skip: spells i32, which the frozen stage0 cannot parse; this milestone adds none (Acceptance 9 stands); src/*.mc and the seed-compiled libraries cannot spell the word on any host (D8 stands, see A4). mc1 --dump-tokens prints the registered id for i32 where mc0 prints 1 -- what every type_new word already does, ungated.

Three consequences that are new and measured.

  1. i32 is reserved program-wide, as f32 is when <float> is loaded (src/hooks.mc:487-489): a program that names a variable or function i32 is refused with name reserved by a syntax/type_alias registration: i32 (docs/reference/hooks.md:251). Measured at 6fab014: grep -rlw i32 --include='*.mc' . finds no file; the cost the draft charged to alternative (b) is zero on this tree.
  2. A module may register i32 again: tok_add returns the existing id for a known lexeme (src/lex.mc:208-213), alias_add appends a second row, and alias_find walks from the end (src/hooks.mc:572, "the last registration wins"). That is M24's rule for any word and stays: a recreated compiler that wants a different i32 gets it by registering one after the core did.
  3. The registered ids shift by one, as under D2 but for the opposite reason: TY_MAX stays 7, the core takes 7, and a module's first id is now TY_MAX + 1 = 8 (ty_f64 7 -> 8, ty_f32 8 -> 9, and so on). Measured the same way as the draft's § What already exists: no file writes a registered id as a number (lib/float.mc:420-422, lib/f16.mc:163, lib/i128.mc:464, examples/avx/avx.mc:233, lib/user_syntax_demo.mc:899-900 all keep globals); lib/machine_probe.mc:53 keys on TY_MAX symbolically; ty_disable_set's mask reaches 62 (src/ast.mc:278). The two doc sentences that say "TY_MAX (7)" (docs/reference/language.md:87, docs/guide/96-a-new-primitive.md:9) stay true; the one that says type_new "appends an eighth id" (language.md:86) becomes "the core appends the eighth, i32; a module's first is the ninth".

The kind: TK_SINT. src/ast.mc:83-86 gains a fifth value, appended so the four existing numbers do not move:

#define TK_SINT   4                   // M45: an integer the core's operators fit, SIGNED

type_new's range check (src/hooks.mc:493) becomes kind > TK_SINT. One predicate, the only place signedness is written down from now on, in src/ast.mc beside type_kind:

// M45: the core decides "signed?" by KIND, not by id. TY_I64 keeps answering
// TK_INT from type_kind (every core type does, and modules key on that), so it
// is named here once.
i64 type_signed(i64 t) { return t == TY_I64 || type_kind(t) == TK_SINT; }

bin_op(op, type_signed(res_type(nd_a(n)))) (src/gen_walk.mc:622, replacing == TY_I64) and const_bin's two tests (src/parse.mc:960, :976, replacing type != TY_I64 / type == TY_I64) read it. res_binary is unchanged: a binary keeps its LEFT operand's type, so i32 / i64 is sdiv, u32 / i32 is udiv, a comparison is i64 and always signed on the 64-bit extended value.

What the kind buys. Every core decision that the draft's type_signed keyed on TY_I32 now keys on TK_SINT plus width: the signed / % >> (bin_op, const_bin), the sign-extending fold of a cast (A2), the sign-extending load, store-at-width and sign-extending cast in the bundled machines (A3), and the walker's narrowing after a call and before a return (A3). So a module -- or the core, later -- registers i16 or i8 with one line, type_new("i16", 2, 2, TK_SINT), and gets ldrsh/sxth, movsx, lh, signed division and comparison, and a call result extended from bit 15, with no line in src/ and no line in a machine that already honours the kind. A signed 24-bit type is type_new("i24", 3, 4, TK_SINT) and gets the same from a machine whose mem_op handles width 3 (neither bundled machine does; that is the machine's answer, per A3's obligation). The draft's Out-of-scope item "i8 and i16 ... ~12 lines each when a caller appears" becomes "one type_new line, from wherever the caller is".

The alternative, named and rejected: a signedness column on type_new. type_new(name, width, align, kind, signed) would change the signature every caller in the tree uses (lib/float.mc:420-422, lib/f16.mc:163, lib/i128.mc:464, examples/avx/avx.mc:233, lib/user_syntax_demo.mc:899-900, lib/user_dupty.mc:7) and every doc line that spells it, add a fifth parallel array to src/ast.mc:88-91 and a fifth grow_to, and give a TK_FLOAT or TK_OPAQUE a "signed" bit that means nothing to the machine. kind is already defined as "what a machine dispatches on when it does not know the exact id" (src/ast.mc:76-77, docs/reference/hooks.md:406), and signedness is exactly a dispatch property of an integer. A new TK_* value is additive: no signature moves, TK_INT..TK_OPAQUE keep 0..3, and a machine that never sees a TK_SINT id emits byte for byte what it emits today. Adopt TK_SINT.

Semantics (the draft's § 2 table stands with TY_I32 read as ty_i32, generalised to any TK_SINT of width w): a load into a depth sign-extends from bit 8w-1; a store truncates to w -- the same instruction as the unsigned type of that width; arithmetic is 64-bit on the extended value and wraps at the next store or cast (D3); / % >> are signed; comparisons are signed on the 64-bit value; (T) x sign-extends from bit 8w-1; fold_cast of a constant does the same at compile time (A2); there is no i32 literal (i32 x = -1 stores 0xffffffff and loads -1); (i32) ld32(p) is the signed read of memory (D15); u32 <-> i32 is a no-op on the stored bytes and on bits 31..0 of a depth. The INT_MIN / -1 paragraph stands verbatim: sdiv on sign-extended operands, 2147483648 on every machine, no trap, INT_MIN again only after a store.

A2. Reconciling with M24's rule, one site at a time #

The rule (src/ast.mc:72-78, CLAUDE.md:1544-1546): "a type id below TY_MAX is a core type and behaves exactly as it always has, byte for byte; an id at or above it was registered, and every core decision about it is delegated." i32 is at or above TY_MAX and every core decision about it is delegated -- to the registry's four columns: width, align and name to the three the core already reads, and signedness to kind, the column the core wrote and, until now, never read (docs/reference/hooks.md:406, "never read by the core"). After M45 the core reads kind in exactly three places -- type_signed, fold_taught and walk_narrow -- and the docs say so. That is the honest restatement; the draft's phrase "a registered type that is secretly core" does not apply, because nothing tests the id.

The four sites the draft named as treating a registered id as taught:

(i) fold_unary/fold_binary/fold_cast and fold_taught (src/parse.mc:998-1049). Today fold_taught(n) is nd_type(n) >= TY_MAX. Left alone, #define M (i32) 0x80000000 is not folded: the node reaches the walker as MTASK_CONST + MTASK_CAST(ty_i32), and the machine's sxtw computes -2147483648 at runtime -- the same value, one instruction, and M / x is still sdiv because res_type of the cast node is ty_i32 and type_signed says so. That is acceptable as a fallback but it is not the recommendation, because fold() and the runtime would then agree by accident rather than by rule, and because a #define of an i32 constant would be the only #define in the language that leaves an instruction behind.

Recommend the kind-based guard. fold_taught becomes:

// M45: the core folds what its own operators compute -- TK_INT and TK_SINT,
// core or registered -- and delegates FLOAT, WIDE and OPAQUE, whose arithmetic
// is the module's. This is the definition of TK_INT in hooks.md ("an integer
// the core's own operators fit") applied to the folder.
i64 fold_taught(i64 n) {
    i64 k = type_kind(nd_type(n));
    return k != TK_INT && k != TK_SINT;
}

with fold_cast's three mask lines (:1043-1045) replaced by a width-and-kind rule that is byte-identical for u8/u16/u32 (same masks) and sign-extends for a TK_SINT:

    i64 w = type_width(t);
    if (w < 8) {
        u64 m = (1 << (8 * w)) - 1;
        v = v & m;
        if (type_kind(t) == TK_SINT && (v >> (8 * w - 1)) & 1) v = v - (m + 1);
    }

It keeps fold() and the runtime in agreement by construction. For a TK_INT or TK_SINT id the runtime IS the core's integer operators: bin_op picks signed or unsigned by type_signed, const_bin picks by the same predicate, and MTASK_CAST extends by the same width and kind the fold masks by (A3). For f64raw (lib/float.mc:422, TK_INT, width 8) the runtime cast is fa_cast -> "int to int, delegate" (lib/machine_arm64_float.mc:378) -> gen_cast, which has no arm for width 8 and emits nothing; the fold masks nothing. For fix (lib/user_syntax_demo.mc:899, TK_INT, 16.16 in eight bytes) 1.5 + 2.5 at runtime is the machine's integer add of the two representations, which is what the fold now computes.

Priced. (a) src/parse.mc: ~10 lines. (b) scripts/check-surface.sh:804-835, the M24 "fold guards" case, asserts the current behaviour on fix: add = 2, neg = 1, mvn = 1, cset = 1 in _main of fix a = 1.5 + 2.5; fix b = -1.5; ..., and CAST type=fix in --dump-ast. Under D17 the four instruction counts flip to what the m24-nofold.mc line asserts for core literals (1/0/0/0), and the case is re-pointed to say so: "a TK_INT literal folds like a core one; a TK_FLOAT/TK_WIDE/TK_OPAQUE one does not" -- the non-folding half moves onto <float>'s f64 (check-float's 12/12 already proves a folded integer add of two IEEE patterns would be caught by value; the instruction-count assertion is the extra, ~6 lines). The --dump-ast assertion stands: fold() runs after the AST dump (:808). (c) lib/user_syntax_demo.mc:621-638's comment ("exercise ... the three folding guards") is edited to say what fix exercises now: the width of a slot and a global, the name, the cast and parameter positions -- and that as a TK_INT it folds. (d) tests/float/015-nan.mc:15 and 019-putf64.mc:18-20 spell (f64) (f64raw) <constant>; the inner cast now folds to an N_INT of type f64raw, res_lit_type (src/gen_resolve.mc:480) preserves that type, and the outer cast sees walk_depth_type(d) == ty_f64raw -> fmov d, x (lib/machine_arm64_float.mc:404, lib/machine_x86_64_float.mc:465) exactly as it does today after the no-op inner cast. Expected: --dump-asm identical for both files; measured, not assumed, in Acceptance 2 (check-float 12/12 plus an object diff of those two). (e) docs/reference/language.md:165-167 ("Folding stops at a type the core did not define") becomes "stops at a kind the core's integer operators do not fit: TK_FLOAT, TK_WIDE, TK_OPAQUE"; CLAUDE.md:1558-1560's M3 sentence is history and stays.

The alternative -- fold only a "core-registered band" (nd_type(n) >= TY_MAX + ncore) -- is two lines and keeps check-surface untouched, but it makes a module's i16 a second-class i32 (same kind, folded differently) and re-introduces an id boundary the ruling moved away from. Rejected (D17).

(ii) res_lit_type (src/gen_resolve.mc:479-482). No change. A literal is TY_I64; there is no i32 literal. The only i32-typed N_INT is one fold_cast produced, and the >= TY_MAX arm returns its type unchanged -- which is what makes M / x signed for a folded #define M (i32) .... Under the draft's TY_I32 = 7 that constant would have resolved to TY_I64; the registered route is at least as good here.

(iii) lib/machine_probe.mc. pr_ty asserts 0 <= t < type_count() at every depth, and --backend=macho-probe-core additionally asserts t < TY_MAX "which is what an untaught program must produce" (:22-24, :53). Both hold: type_count() is 8 after core_types_init, and src/mc.mc -- the program the probe runs over -- does not spell i32 (D8), so no depth ever carries 7. A program that uses i32 is teaching in the M24 sense -- it has a depth whose type came from the registry -- and probe-core would refuse it by design, exactly as it refuses a <float> program; that flag means "no registered type at any depth", and stays that. No change to the probe; a sentence in its header.

(iv) type_disable. type_disable(TY_I32) becomes type_disable(ty_i32); type_disable checks ty < type_count() (src/hooks.mc:535) and core_types_init ran before user_init, so the AVR compiler's user_init (examples/avr/mc-avr.mc, branch m40-avr) calls it and every position that goes through type_of_token (src/parse.mc:714-715) answers i32: removed by this compiler, with the name from the registry. Bit 7 of ty_off.

A3. The machines, by kind: two bundled, two taught -- contract version 4, no slot added (replaces § 4) #

Today all four machines key the width-carrying slots on the three core ids: mem_op (src/machine_arm64.mc:171-178), x86_mem_op (src/machine_x86_64.mc:208-214), rv_mem_op (examples/kernel/machine_riscv64.mc:188-192) map TY_U8/U16/U32 to an index and everything else to the 8-byte pair; gen_cast (:192-196), x86_cast (:383-388), rv_cast (:363-375) have three arms and no-op for everything else. For a registered id of width 4 that is already silently wrong -- an 8-byte str into a 4-byte global clobbers its neighbour, and a cast to it does nothing -- so the obligation below is not new in kind, only now written down.

The rule every machine owes, per ty-carrying slot (MTASK_PARAM, MTASK_CAST, MTASK_LOAD, MTASK_STORE, MTASK_LOCAL_LOAD/STORE, MTASK_GLOBAL_LOAD/STORE): dispatch on type_width(ty) and type_kind(ty), never on the id.

slot, for w = type_width(ty), k = type_kind(ty)TK_INT, w < 8 (u8/u16/u32 and any registered one)TK_SINT, w < 8 (i32, or a module's i16/i8)w = 8, or TK_FLOAT/TK_WIDE/TK_OPAQUE
LOAD, LOCAL_LOAD, GLOBAL_LOADzero-extend: ldrb/ldrh/ldr w; movzx/movzx/mov r32; lbu/lhu/lwusign-extend: ldrsb/ldrsh/ldrsw; movsx/movsx/movsxd; lb/lh/lw8-byte load; the module's for the three kinds
STORE, LOCAL_STORE, GLOBAL_STORE, PARAMtruncate to w: strb/strh/str w etc.the same instruction8-byte store; the module's
CASTzero-fill from w: and #0xff / and #0xffff / mov wd, wnsign-fill from bit 8w-1: sxtb/sxth/sxtw; movsx/movsx/movsxd r64, r32; shift pairs / addiw rd, rd, 0no-op; the module's

MTASK_PARAM needs no new instruction: it stores at w (a64_param, src/machine_arm64.mc:296-300), and the sign lives in the re-read.

The walker (D4 and D5 restated by kind). Beside walk_word() (src/gen_walk.mc:358):

// M45: a type whose value at a depth is DEFINED by extension from its width --
// zero for TK_INT, sign for TK_SINT -- and narrower than the 8-byte depth. The
// walker performs that extension where a foreign register hands it a bare view:
// after a call and before a return. Core or registered makes no difference;
// FLOAT, WIDE and OPAQUE are the module's (D7). uptr is excluded: a pointer is
// the machine's word and is never narrow relative to itself (AVR: 2 bytes).
i64 walk_narrow(i64 t) {
    i64 k = type_kind(t);
    if (k != TK_INT && k != TK_SINT) return 0;
    if (t == TY_UPTR) return 0;
    return type_width(t) < 8;
}

gen_call (src/gen_walk.mc:762-772) issues set_walk_depth_type(depth, rt); callp(mach(MTASK_CAST), rt, depth); after MTASK_CALL when walk_narrow(rt) -- the draft's § 3 code, with the new predicate; the set_walk_depth_type line stays load-bearing for fa_cast/fx_cast (lib/machine_arm64_float.mc:377). N_RETURN (:904-908) issues MTASK_CAST(walk_fn_ret, 0) before MTASK_RET(0) when walk_narrow(walk_fn_ret). type_width(TY_VOID) answers 8 (src/ast.mc:295), so void is excluded without a test. So a REGISTERED narrow integer -- a module's i16 -- is narrowed by the walker after a call exactly as i32 and u8 are; f32 (TK_FLOAT, width 4) stays <float>'s, whose MTASK_CALL already returns it from s0 itself.

One consequence for m40-avr, to measure: u8/u16 are TK_INT of width 1/2, so on AVR the walker now issues MTASK_CAST after every call declared u8/u16 and before every such return. The AVR callee already zero-extends its result to eight bytes (docs/reference/machine.md:380), so avr_fz after the call is redundant, not wrong, and the image bytes of examples/avr move where the corpus declares narrow functions (the draft measured none in examples/kernel; examples/avr/tests must be measured on the branch). The bundled machines are unaffected: nothing on main declares a narrow callee (§ What already exists).

Per machine.

machineloads (mem_op-class)castlines
src/machine_arm64.mcmem_op(t, store) by w and k: three new pairs appended to mem_ins[] (:140) in the same even-load/odd-store shape, I_LDRSB 0x39800000, I_LDRSH 0x79800000, I_LDRSW 0xB9800000 (unsigned offset, scaled 1/2/4), sharing I_STRB/STRH/STRW; d_mem prints an x destination for themgen_cast(rd, ty) by w and k: I_SXTB 0x93401C00, I_SXTH 0x93403C00, I_SXTW 0x93407C00 (SBFM xd, xn, #0, #7/15/31), | rn<<5 | rd; the three TK_INT arms keep their instructions~22
src/machine_x86_64.mcx86_mem_op by w/k: X_LDS8 (XF_LD, 1, 0x1be), X_LDS16 (XF_LD, 1, 0x1bf), X_LDS32 (XF_LD, 1, 0x63) -- movsx r64, [m] twice and movsxd r64, [m]; REX.W is already present (w = 1), so sil/dil need no forced-REX column (compare row 39, :159)x86_cast by w/k: X_MOVSXB (XF_RD, 1, 0x1be), X_MOVSXW (XF_RD, 1, 0x1bf), X_MOVSXD (XF_RD, 1, 0x63), modelled on rows 29/30 (:149-150)~16
lib/backend_arm64.mc (the surface encoder check-surface compares 32/32)the three signed-load rowsthe three SBFM forms~8
examples/kernel/machine_riscv64.mc (taught)rv_mem_op by w/k: lb/lh/lw (opcode 0x03, funct3 000/001/010), names in rv_mname (:123)rv_cast: slli 56; srai 56, slli 48; srai 48, addiw rd, rd, 0 (sext.w)~14
examples/avr/machine_avr.mc (branch m40-avr)-- its invariant "every core type but i64 is unsigned, so extend always means zero" (:20-27) is stated for a compiler that registers no TK_SINT and disables the one the core doestype_disable(ty_i32) in mc-avr.mc's user_init, a README sentence; sign-fill is a later ask~3

Byte identity for the seven core ids is structural: type_width and type_kind answer for TY_U8/U16/U32 exactly the numbers the id tests selected, and the TK_INT arms keep their instructions. lib/machine_arm64_float.mc / lib/machine_x86_64_float.mc need nothing: fa_cast/fx_cast delegate int-to-int (:378, :438), fa_is_float keys on type_kind == TK_FLOAT, and TK_SINT is not that. lib/machine_probe.mc delegates every slot and asserts nothing about kinds.

Contract version 4. docs/reference/machine.md's header (:3) becomes "version 4 -- ... and the kind obligation (M45)". Like 2 -> 3 (:26-31) it appends no slot and changes no signature; the bump is what a machine outside src/ must honour: the ty of every ty-carrying slot may be a registered TK_INT or TK_SINT id of width 1, 2, 4 or 8, and the machine owes extension by kind -- zero for TK_INT, sign for TK_SINT -- in its loads and casts, and truncation by width in its stores. § 2's rows are reworded: MTASK_CAST "extend depth d to the type's width by its kind" (:132, was "narrow ... to that width"); MTASK_LOAD "zero- or sign-extended by kind" (:133); :163-166 "only i64 divides and shifts with sign" becomes "i64 and every TK_SINT"; the slot invariant (:421-424, "bytes above w zeroed (every core type but i64 is unsigned)") becomes "bytes above w filled by the kind: zero for TK_INT, sign for TK_SINT and i64". The obligation, stated once: a machine that keys on the core ids and ignores TK_SINT is silently wrong -- its MTASK_CAST(ty_i32) is a no-op and its MTASK_LOCAL_LOAD(ty_i32) reads eight bytes -- and a machine that will not implement the kind must type_disable every TK_SINT word from its user_init. The bundled two and riscv64 get their arms in this milestone; AVR disables the word.

Every new instruction goes through the llvm-mc sweep (CLAUDE.md § M24 step 1): ldrsb/ldrsh/ldrsw and sxtb/sxth/sxtw (mach-o and elf aarch64), movsx byte/word and movsxd, register and memory forms (elf and coff x86-64), lb/lh/lw/addiw/srai (riscv64).

A4. What survives, and what is renumbered #

A5. Amended decisions (architect) -- to ratify with the owner #

Implementation notes #

Written while implementing, in the order the work uncovered them. Every number here was measured on this host (macOS 15, Apple silicon, Docker Desktop, LLVM 21 in /opt/homebrew/opt/llvm) and each is reproducible with the command shown.

1. Acceptance 1 -- the measurement, with the PRE-milestone compiler #

The program is extern i64 f(...) for every function, exactly as lib/sys.mc and src/arena.mc declare them today, printing the raw 64 bits of the result register in hex. Built with the build/mc1 of 6fab014 (the merge-base), never with a compiler carrying this milestone. Linux binaries were produced with M42's --exe (D14's recommendation), so no linker and no sysroot are involved; interp/libc in mc.toml pick glibc.

(a) and (b), Linux, all four combinations. open("/nonexistent-m45", 0, 0), close(-1) and waitpid(-1, 0, 0) came back as a full 64-bit -1 everywhere:

openclose(-1)waitpid(-1)fd < 0 taken?
musl aarch64 (alpine:3)0xffffffffffffffff0xffffffffffffffff0xffffffffffffffffyes
glibc aarch64 (ubuntu:latest)0xffffffffffffffff0xffffffffffffffff0xffffffffffffffffyes
musl x86_64 (alpine:3)0xffffffffffffffff0xffffffffffffffff0xffffffffffffffffyes
glibc x86_64 (ubuntu:latest)0xffffffffffffffff0xffffffffffffffff0xffffffffffffffffyes

So the open hazard is latent and not observed, which is what the spec's opening paragraph now says after M42's own re-measurement, and this run confirms it independently on four combinations. A syscall wrapper is hand-written assembly in both libcs and both of them happen to leave a sign-extended value in the result register.

The defect class itself does reproduce, on ORDINARY compiled C, which is what the milestone is actually about. Same program, same pre-milestone compiler, two more extern i64 declarations:

atoi("-1")strcmp("a","b")
musl aarch640x00000000ffffffff -- < 0 NOT taken0x00000000ffffffff -- NOT taken
glibc aarch640xffffffffffffffff0xffffffffffffffff
musl x86_640x00000000ffffffff -- NOT taken0x00000000ffffffff -- NOT taken
glibc x86_640xffffffffffffffff0x00000000ffffffff -- NOT taken

Every one of the four legs has at least one function whose int result the pre-milestone compiler reads as a large positive number. tests/linux/072-int-return.mc therefore declares all three -- open, atoi, strcmp -- and its header says which is the reproducing one on which leg.

(c) macOS. libSystem's open, close(-1), waitpid(-1), atoi("-1") and strcmp("a","b") all answer 0xffffffffffffffff. This decides lib/sys.mc's row in § 5: its declarations STAY i64. But an ordinary C function is a different matter, and the sharpest single measurement in this milestone is on macOS: int m45_neg(void) { return -1; } compiled by clang -O2 into a dylib is

_m45_neg:
  mov w0, #-0x1
  ret

and the pre-milestone compiler, calling it through extern i64 m45_neg(), reads 0x00000000ffffffff and does not take v < 0. That is clang's own code generation, on the host, and it is the same shape examples/api's SQLite wrappers and examples/desktop's GTK4 ones have been relying on by accident.

(d) Windows: not measurable here. There is no Windows machine and no container that could hold one in this repository's development loop (tests/golden/README.md says the same about the two Windows goldens). GetFileAttributesA returns a DWORD and both Windows ABIs leave the upper half of the result register unspecified, which is exactly why lib/sys_windows.mc has masked every BOOL/DWORD with & BOOL_MASK by hand since M19. tests/windows/073-int-return.mc is the assertion, and the windows-11-arm / windows-2025 CI legs are the only place it runs.

2. Deviations from the amended design #

  1. core_types_init() has THREE call sites, not one. A1 names src/cli.mc only. mc build and mc limits do not go through mc_main's pipeline: drv_parse (src/driver.mc) and lim_compile_file (src/limits.mc) each run their own tok_init / lex_init / user_init sequence. Found by evidence, not by reading: with the single call site, make check-avr failed with examples/avr/lib/sys_avr.mc:45: void: removed by this compiler -- ty_i32 was still 0 in the spawned child, so type_disable(ty_i32) disabled TY_VOID. One line in each of the two files, in the same place and with the same comment as cli.mc's.
  2. The return-side cast does NOT rewrite the depth type before the cast, only after. A3's text carries the call-side set_walk_depth_type over to both. On the call side the line is load-bearing because dtype[d] is stale (it describes argument 0); on the return side it is already correct -- it describes the value gen_value just produced, which is precisely the SOURCE a derived machine's MTASK_CAST needs. Rewriting it first would tell <float>'s fa_cast that a f64 being returned from a u8 function was already an integer. The write is done AFTER the cast instead, so MTASK_RET still sees the declared type.
  3. mem_op and gen_cast now key on type_width(TY_UPTR) too, because A3's rule is "width and kind, never the id" and uptr has no exemption there. With the default 8-byte word nothing moves. With type_set_width(TY_UPTR, 2) (M41) the arm64 machine now stores and loads a uptr local at TWO bytes where it used to use the 8-byte pair -- which is a fix, not a regression: the frame slot for such a local is 2 bytes wide, so the old 8-byte str wrote past it. check-parts asserts the frame size and not the instruction, so it is green either way, and no shipped compiler is affected (examples/avr has its own machine). walk_narrow DOES exempt uptr, as A3 specifies -- a pointer is the machine's word and is never narrow relative to itself.
  4. fold_cast narrows a constant (uptr) cast when the declared word is narrower than 8. Same cause as 3 and the same conclusion: it agrees with what avr_cast does at run time (avr_fz(d, avr_tw(TY_UPTR))), so fold() and the runtime still agree. Measured: the AVR image does not move (below).
  5. examples/kernel/machine_riscv64.mc needed a funct7 column on the I-type table, because srai is srli with 0x20 in the high seven bits of the immediate field and A3 asks for the slli/srai pair. Every existing row has 0 there, so the encoding of every instruction that existed before is unchanged. sext.w is a new opcode (V_ADDIW, opcode 0x1b), as A3 asks.
  6. lib/machine_arm64_float.mc and lib/machine_x86_64_float.mc were NOT touched, which A3 predicted. What was checked rather than assumed: fa_cast's float-to-integer arm picks fcvtzs only for TY_I64 and fcvtzu otherwise, so (i32) <a double> converts unsigned and then sign-extends. That is reachable for the first time in M45 and is a real difference from C for a negative double. It is left alone in this milestone -- changing it would move bytes in a library the float legs gate -- and is written down here as the one known rough edge.
  7. scripts/check-inert.sh cannot run its examples/kernel case across this milestone. The PRE compiler builds the taught compiler out of ITS OWN bundle, and the updated machine_riscv64.mc names TK_SINT, a constant that bundle does not have (unknown name). The equivalent proof was done by hand and is in § 3 below: pre compiler + pre machine, post compiler

    • post machine, cmp on the image.

3. Commit 1 -- the mechanism, measured #

Inertness. scripts/check-inert.sh build/mc1.pre build/mc1, where build/mc1.pre is a copy of build/mc1 taken from 6fab014 before the first edit:

ok   33 objects identical (tests/*.mc and src/mc.mc)
DIFF taught examples/api     -> build/api
ok   taught examples/lang    -> build/lang-demo
ok   taught examples/conc    -> build/conc-demo
ok   taught examples/desktop -> build/desktop-ui

Corrected (review, 2026-09-05). An earlier version of this note recorded ok on the api row. It is a DIFF, reproduced with mc1.pre rebuilt from 6fab014 and mc1 from 9e27e06, and the correction matters more than the row: the corpus grep § What already exists relies on cannot see a narrow declaration a Tier 3 module SYNTHESIZES, because there is no source text to grep. examples/api/oop.mc's class handler builds a getter per field out of AST nodes (oop_getter -> oop_func(ty, ...)), and class Todo { ... bool done; } in examples/api/main.mc -- with bool = type_alias("bool", TY_U8) from mc-api.mc -- is a declaration of u8 todo_done(uptr self) that no grep over examples/ can find. D5 then applies to it on BOTH sides, which is the design and not an accident. Measured, --dump-asm of examples/api/main.mc through the taught mc-api each compiler builds differs by exactly two instructions in 3300 lines:

_todo_done:            ...  ldrb w9, [x9]         the return side: walk_fn_ret is u8
                          + and x9, x9, #255
... bl _todo_done;         mov x9, x0             the call side: res_type(n) is u8
                          + and x9, x9, #255

Both are no-ops on the value -- ldrb already zero-extends, and the callee now guarantees the extension the caller repeats -- so build/api comes out the same 55632 bytes and check-examples (the eleven route checks of examples/api/test.sh) is green on the post side. It is the price of "a call returns what it declares" being a property of the DECLARATION rather than of the spelling, and the same two instructions would appear for a hand-written u8 function. The other four taught examples have no narrow-declared function, synthesized or written, and are byte-identical.

The examples/kernel row of the script cannot run across this milestone at all: the pre compiler's bundle has no TK_SINT, so it cannot build the post machine_riscv64.mc and the row is FAIL taught examples/kernel (pre, entry). It, and the AVR image, are measured by hand instead -- the two taught machines whose SOURCE this milestone changes:

The seed. git diff stage0/ is empty; check-obj is 32/32 against build/mc0.

The llvm-mc sweep, over a program that uses i8, i16 and i32 (the two extra widths come from a throwaway module in build/, which is gitignored -- the point is to reach every arm of the new tables, including the two the tree has no caller for):

targetdistinct new instructionsmismatches
arm64 (mach-o)160
aarch64 (elf)160
aarch64 (coff)160
x86_64 (elf)160
x86_64 (coff)160

and for riscv64, through examples/kernel/test.sh's own sweep, with tests/sweep.mc extended by an i32 section: tests/sweep.mc 285 distinct instructions, 0 mismatches (262 before), plus a separate scratch run with i8/i16 registered -- 62 distinct instructions, 0 mismatches, containing lb, lh and both srai shapes.

lib/backend_arm64.mc, the surface encoder, was checked the way check-surface checks it but on a program that uses the new instructions: --backend=arm64-surface and the built-in backend produce byte-identical objects for a source with i8/i16/i32 locals, globals, arrays, parameters, casts and calls.

The load-bearing line. With set_walk_depth_type(depth, rt) removed from gen_call, the <float> compiler lowers extern i32 ilogb(f64 x) as

  bl _ilogb
  mov x9, x0
  fcvtzu x9, d16        <- a float register that never held this value
  sxtw x9, w9

and with it as mov x9, x0 + sxtw x9, w9. tests/float/022-int-return.mc is the regression.

Cost in src/ (git diff --numstat, and added lines that are neither a comment nor blank):

fileaddedof which code
src/ast.mc142
src/hooks.mc193
src/cli.mc41
src/driver.mc11
src/limits.mc11
src/parse.mc2312
src/gen_walk.mc5822
src/machine_arm64.mc6746
src/machine_x86_64.mc4740
total234128

against A4's estimate of ~70 new src/ lines / ~40 code. The gap is entirely in the two machines (86 of the 128), which A4 counted in the per-machine table rather than in the src/ total.

make check green end to end, RC 0, zero FAIL (5m09s): test 32/32, check-lex 126/126 (2 skipped), check-ast 126/126, check-asm 126/126, check-obj 32/32 identical to the frozen seed, check-bundle, bootstrap at a fixed point (mc2.o == mc3.o, 926448 bytes; the --dump-asm diff between mc1 and mc2 is empty), check-surface 32/32 plus the three new abi_cases, the re-pointed fold case and the i16 case, test-exe 32/32, check-mc 11/11, check-standalone, check-toml 10/10, check-build 31/31, check-stubs 9/9, check-sysroots, check-limits 17/17 under 90%, check-minimal, test-linux 39/39, test-linux-x86_64 36/36, test-linux-exe 42/42 musl + 42/42 glibc, test-linux-x86_64-exe 39/39 + 39/39, test-windows 40/40 objects + 3 linked, test-windows-x86_64 38/38 + 3 linked, check-examples, check-lang, check-conc, check-desktop, check-kernel (QEMU, exit 0), check-avr (simavr + QEMU), check-float, check-wide, check-docs (185 symbols, 19 flags, 19 TOML keys, 10 directives, 48 samples, 273 links), site 85 pages + check-site 0 problems. make check-linux-host green for both architectures (RC 0), with the cross proof (mc2l --backend=macho src/mc.mc byte for byte the macOS build/mc2.o) on musl and glibc.

4. Commit 2 -- the declarations, and the one place D8 did not survive #

D8 as written is incompatible with check-asm, and the code said so before any argument did. § 5 asks src/*.mc and the seed-compiled libraries to declare an int result as u32 and read the sign through c_int(). Implemented literally, make check came back RC 2 with 23 FAILs:

FAIL src/arena.mc
FAIL src/mc.mc
FAIL lib/sys_windows.mc
FAIL src/host_windows.mc
... 23 in all
103/126 files identical
make: *** [check-asm] Error 1

scripts/check-asm.sh compares build/mc0 --dump-asm FILE against build/mc1 --dump-asm FILE over tests/*.mc tests/lib/*.mc lib/*.mc src/*.mc. A u32 declaration makes mc1 emit a mov w9, w9 after the call that the frozen seed, which has no narrowing, does not — the delta over src/mc.mc was exactly 27 mov w9, w9 instructions and nothing else, measured with diff <(mc1 --dump-asm src/mc.mc) <(mc1.pre --dump-asm src/mc.mc). There is no way to have both: either the seed set keeps the seed's codegen, or its declarations are narrow. The two escapes are closed by the task's own rules — a seed-skip header is forbidden, and stage0/ is frozen.

Resolution: c_int() alone in the seed set, i32 everywhere else. That is D8's own named alternative ("keeping i64 and adding c_int alone is fewer edits but leaves the compiler as the one place whose declarations lie"), and it is taken here because a hard gate demands it and not as a preference. It is also not weaker: c_int(v) takes the low 32 bits and sign-extends from bit 31, so it produces the right answer whether the callee left the sign in place or not, on every host and under every seed. The compiler's own open/creat/waitpid hazard is fixed either way; what is lost is only that the declaration itself would have carried the information.

So, per file:

filedeclarationsreads
src/arena.mcunchanged (i64)c_int() added (14 lines, the helper); c_int(open(...)), c_int(creat(...))
src/sysroot.mcc_int(open(...))
src/driver.mcc_int(waitpid(...)) at both sites -- the same latent defect as open, and the one a spawned linker's pid_t would hit
src/backend_exe.mcunchangedc_int(creat(...))
src/host_{macos,linux,windows}.mcunchanged, with the reason in a comment
lib/sys_windows.mc, lib/sys_windows_host.mcunchanged; the & BOOL_MASK masks stay (D9)
lib/sys.mcunchanged, by measurement (Acceptance 1c), with the numbers in its header
examples/api/lib/sqlite.mc11 -> i32 (last_insert_rowid stays i64)
examples/api/lib/http.mc5 -> i32the three < 0 tests are now sound
examples/api/tests/lib_test.mcunlink -> i32
examples/conc/lib/{macos,linux}/thread.mc12 and 12 -> i32
examples/desktop/lib/gtk.mc, main.mc2 -> i32; both (u32) casts dropped

examples/api/test_sqlite_lib.mc is in § 5's table but was left alone: nothing builds it (no Makefile target, no script, no CI step) and it does not compile at all -- test_sqlite_lib.mc:4: call to unknown function, because sqlite3_libversion_number is declared nowhere. Recorded rather than fixed; it is an orphan from M12 and not this milestone's business.

Measured.

make check RC 0, zero FAIL (5m08s), with check-asm back at 126/126 and check-obj 32/32; make check-linux-host RC 0 on both architectures with the cross proof on musl and on glibc; check-examples, check-conc, check-desktop green with the truthful declarations. All five goldens rewritten a second time.

5. Commit 3 -- p_cp() #

Not part of the spec; asked for alongside it because the ngen consumer hit it. p_start() is where the CURRENT TOKEN starts, and on a token p_subst_name() replaced, subst_apply (src/lex.mc) swaps tok_start/tok_len for the replacement string, which lives in the arena. So a syntax_lit-style handler that scans raw source forward from p_start() inside a p_push_source frame reads the arena lexeme and not the source it meant to read. p_cp() is one line in src/parse.mc, beside p_start() and p_src_end(): the lexer's cursor, which still points into the pushed text just past the token.

Measured, in scripts/check-surface.sh (p_cp-under-substitution, through the demo compiler): srcbyte reports ld8(p_cp()), srcbyte0 reports ld8(p_start()), and probe p1; pushes i64 p1() { return W * 1000 + V; } with W -> srcbyte and V -> srcbyte0, so both run on SUBSTITUTED tokens.

valuewhat it means
srcbyte in ordinary source59the ; that really follows the word
srcbyte0 in ordinary source115's' — outside a substitution the two positions agree
W inside the pushed frame, through p_cp()32the SPACE that really follows W in the pushed text
V inside the pushed frame, through p_start()115's', the arena copy of "srcbyte0" — the source there holds V, 86

Inert: scripts/check-inert.sh between the commit-2 compiler and this one is identical everywhere — 33 objects and all five taught examples, examples/kernel included — and the mc1/mc2 --dump-asm diff over src/mc.mc is empty. The goldens move because p_cp is a new function in src/parse.mc, not because anything the compiler emits changed.

6. The review batch (2026-09-05) #

Four findings from the reviewer of the branch. The first is a correction to this document (§ 3 above and § 3's "Inertness, exactly" in the Design, both rewritten in place); the other three are code and documentation.

  1. examples/api was never inert under commit 1 -- corrected where it was claimed, with the reason: the "no narrow-declared callee anywhere" grep is over source text and a Tier 3 module can SYNTHESIZE one. See § 3.
  2. c_int was not in docs/reference/. Acceptance 8 promised it documented and it appeared only in this spec and in CLAUDE.md; scripts/check-docs.sh's symbol regex had no prefix that matched it, so the gate could not notice. Documented in docs/reference/language.md § 6 (beside "a call returns what it declares" and the extern rule) and cross-referenced from docs/reference/hooks.md § The host layer, which is where the declarations it exists for live. The extraction regex gained c_ -- the same widening on_ and decl_ got (docs/specs/M26.md) -- and the gate was verified in both directions: with the two mentions renamed it prints FAIL undocumented public symbols, and with them it prints docs ok: 187 symbols.
  3. rv_if7[] was a funct6. RV64I's shift-immediate forms take a 6-bit shamt (bits 25:20), so their differentiator is a funct6 at bits 31:26, not the funct7 of the register forms (5-bit shamt). The single value in the column, 0x20 << 5, lands on bit 30 -- exactly where 0x10 << 6 lands -- so the encoding was right and only the name and the shift were wrong; a second, multi-bit value would have been misplaced. Renamed to rv_if6/rv_if6_at, value 0x10, shift << 6, with the reason in the comment. examples/kernel/build/kernel.bin is cmp-identical before and after (3304 bytes) and make check-kernel is green, including the llvm-mc sweep (main.mc 234, tests/sweep.mc 285, the generated source 1079 distinct instructions, 0 mismatches). The kernel corpus has no srai -- it comes only from rv_cast's sign-extension pair, which needs a 1- or 2-byte TK_SINT -- so the arm was re-proved on the scratch compiler of § 3, mc-kernel plus type_new("i16", 2, 2, TK_SINT) and i8: 36 distinct instructions, 0 mismatches, with srai t3, t3, 48 = 135e0e43 and srai t4, t4, 56 = 93de8e43 re-assembled byte for byte by llvm-mc -triple=riscv64 -mattr=+m.
  4. p_skip_balanced refused a region that ends flush with the end of an included file. The frame depth was compared AFTER the lookahead next() that follows the closing delimiter, and lex_next pops an exhausted #include frame before it produces a token -- so a perfectly balanced region whose } was the include's last token left nopen one lower and was refused with region crosses a file boundary. Reported by the teko/ngen consumer. The depth is now sampled at the CLOSER, inside the loop, which is exactly the "both delimiters live in one buffer" the rule always meant; an #include opened and closed inside the region still moves nopen up and back down and is still fine, and a region that really does cross is still refused at the opening token. Two cases in scripts/check-surface.sh (p_skip_balanced-include-eof, which compiles a tmpl living alone in its own file and runs it for exit 42, and p_skip_balanced-cross, which asserts the message and its position); the message had no test at all before. Documented in docs/reference/hooks.md § Record and replay, docs/reference/diagnostics.md and docs/surface.md.

7. What the Windows CI legs found (2026-09-05) #

The four Windows jobs -- the only place in this project where a Windows binary is LINKED against a real toolchain and RUN -- were red on the branch while everything on the development machine was green. Two findings, the same on windows/arm64 and on windows/x86_64, plus the gate change that makes the first of them impossible to miss again.

  1. tests/windows/073-int-return.mc was in the wrong link mode. scripts/test-windows.sh put it in the self list, and self means one thing only: the source includes <sys_windows>, so it carries the layer itself and winrt.obj must not be linked next to it. 073 deliberately does NOT include the layer -- its header says so, and that is the point of the file: it declares the three kernel32 entry points it uses and depends on nothing. But winstart.obj is in EVERY link line (M20: mc_start lives there and nowhere else) and mc_start calls win_setup/win_argv, which live in lib/sys_windows.mc = winrt.obj. So the link failed with two undefined symbols:

    lld-link: error: undefined symbol: win_setup

    referenced by C:/a/mc/mc/build/windows-objs/winstart.obj:(mc_start)

    lld-link: error: undefined symbol: win_argv

    073 is now a kernel32 link (test object + winrt.obj + winstart.obj + kernel32.lib): its own externs resolve from the import library and none of its names (wr, puti, nbuf, nio, main) collides with the layer's (write, read, open, creat, close, exit, win_*). The manifest is what both halves of the split read, so moving the test moves it for --build-only and --run-only at once.

  1. close(-1) answered 0 on Windows, and -1 everywhere else. lib/sys_windows.mc's close passed anything outside 0..2 straight to CloseHandle, and (HANDLE)-1 is not only INVALID_HANDLE_VALUE: it is the pseudo-handle GetCurrentProcess() returns, and CloseHandle on a pseudo-handle SUCCEEDS. So the wrapper reported success for a close of an invalid descriptor, close(0 - 1) < 0 was false, and tests/mc/093-i32-return.mc printed -1 44 -32768 1 0 where the four other targets print -1 44 -32768 1 1. That is a defect of the LAYER, not of the test -- a POSIX close of an invalid descriptor is -1/EBADF -- so close now refuses a negative descriptor itself, with the pseudo-handle reason written next to the line. lib/sys_windows_host.mc needed nothing: it #includes lib/sys_windows.mc and has no close of its own. 093's header carried the false claim in prose ("close(-1) is CloseHandle(INVALID_HANDLE_VALUE) and therefore -1 too") and was corrected with the fix. lib/sys_windows.mc is bundled (sys_windows), so the fix moves the blob and therefore all five goldens -- and only the blob: the --dump-asm diff between mc1 and mc2 is empty and scripts/check-inert.sh is identical everywhere.
  1. The local gate could have seen (1) and did not. The default mode of scripts/test-windows.sh linked THREE objects out of forty -- one per link mode, plus the one that pulls the layer in through an extern -- so a test classified into the wrong mode was invisible here and CI was the first to say so. An undefined symbol is a property of the pair (object, link mode), and lld-link is on the development machine, so the default mode now links every object in the manifest with its recorded mode, keeps the IMAGE_FILE_MACHINE_* assertion per linked .exe, and reports the count. Measured here: 40 executables linked for windows/aarch64, 38 for windows/x86_64, nothing executed. Proved to have teeth by putting 073 back in the self list: make test-windows then fails with the exact CI message (FAIL 073-int-return (link: lld-link: error: undefined symbol: win_setup), and passes with the classification fixed. The --run-only half is unchanged.

    Widening the gate immediately paid for itself a second time: it reported undefined symbol: GetFileAttributesA on a machine whose build/sysroot/windows-aarch64 had been populated BEFORE M45 added that name to the list. scripts/sysroot-windows.sh treated the directory as a cache keyed on the mere existence of kernel32.lib; it now compares the generated kernel32.def against the one on disk and repopulates when they differ, so a stale cache cannot present itself as a missing entry point. CI never saw this -- it builds the sysroot fresh in every run -- which is exactly the class of divergence between the local gate and CI this section is about.

What only the Windows runners can prove. Both fixes are verified here as far as a macOS machine can verify them: the objects cross-compile, every one of them links with lld-link, and close's new first line is ordinary mc code compiled by the same compiler for all five targets. Whether close(-1) now RETURNS -1 on Windows -- and therefore whether 093 prints -1 44 -32768 1 1 there -- is a claim only the windows-11-arm and windows-2025 legs can settle, because nothing Windows is ever executed in this repository's development loop.

Edit this page