Spec M52 -- the standard library becomes installable, and the binary carries only the compiler's own source

Written against minicompiler/mc main at c7b8cfc (version constraints for [deps]), read-only session except for the probes of § 1.3, 2026-09-08, on the Apple M4 / macOS 26.6.2 host ../comparison.md measures. There is no docs/plan.md row to quote: this milestone starts from the owner's direction, and ../plan.md's row is written from this spec and not the other way round.

The owner's direction, verbatim (2026-09-08):

M51 não violaria se fosse lib, não bundle, e mesmo os bundles podemos converter em libs que podem ser instaladas (caso não existam) no momento do build de uma aplicação. Dessa forma o binário do mc fica mais enxuto, portando somente o que é estritamente essencial e verdadeiramente dependência.

The one-paragraph version. The measurements move this milestone from "carve the libraries out into packages" to something much smaller, and they do it twice. First, the size: applying the owner's own rule per bundle row -- a row stays iff the compiler itself is built from it -- takes 41 of 101 rows and 123 315 of 678 368 blob bytes out, which is 10.0% of the binary (1 408 576 -> 1 268 276 B, measured by regenerating the blob and rebuilding, § 1.2) and not one byte of __text, because the essential set is the compiler's own source and that is 82% of the blob. Second, the mechanism: the road that serves a library name the blob no longer carries already exists and needed no design at all -- resolution step 3, <libs>/mc/v<version>/, reached on a bundle miss (../reference/packages.md § 2), which § 1.3 proves end to end on this host: a thinned compiler refuses #include <sys>, mc install --from-tree . populates the tree in 0.28 s with no network, and the same compiler then builds a taught float compiler out of <mc/core> + <user_float> whose object is cmp-identical to the one the unthinned compiler's taught compiler writes. So M52 is not a packaging milestone: it is a one-predicate change in tools/bundle.mc, two more roots in dp_mc_load so that the tree ships beside the binary and the offline promise survives, one widened refusal, and the test and doc migration. Zero #include lines change anywhere -- <float> is spelled <float> before and after -- which is what makes the 0.16.0 cut safe for the consumer. Publishing float, i128, io as registry packages of their own stays priced and deferred (§ 9), for one measured reason: only ONE bare name per package survives (a package's lib), so <machine_arm64_float> would have to become <float/machine_arm64_float.mc> in every consumer, and the single-file CLI has no lock road at all.

0. What this stands on (read, not assumed) #

1. The measurements (step 0), and what they decide #

1.1 The blob today, with the owner's rule applied per row #

src/bundle_data.mc's bundle_idx carries a csize and an rsize per entry, so the cost of every name is readable without a build: 101 entries, raw 1 479 468 B, compressed 677 117 B, blob 678 368 B (the blob is the names plus the streams; the index is another 3 232 B). Applying "a row stays iff the compiler is built from it" -- which by § 0's last-but-three bullet is the path is under src/, plus prelude and user_default:

classrowsrawin the blob
stays -- every src/ path (58), prelude, user_default601 223 798554 279
leaves -- every lib/ path but two, and the two tests/mc/bundle/ payloads41255 670122 838

The 41 rows that leave, by what they are and what they cost (csize, bytes of blob):

whatrowsnamesblob
the float library and its two machines6float 7 363, machine_x86_64_float 11 184, machine_arm64_float 10 736, float_rt 2 071, user_float 499, mc_float 98632 839
the wide integers4i128 16 563, u128 414, mc_i128 230, mc_u128 22517 432
half precision2f16 3 653, mc_f16 1 0644 717
the system layers and stdio10sys_windows_host 7 658, sys_windows 5 200, sys_linux_x86_64 3 736, sys_linux_aarch64 2 868, sys 2 493, sys_windows_start 1 952, sys_linux 1 496, sys_svc 1 337, io 792, u128's door counted above27 532
the teaching demos and probes (check-surface, check-standalone, check-asm fixtures)17user_syntax_demo 21 005, machine_probe 4 574, backend_arm64 4 546, syntax_demo_test 1 965, user_badmach 1 186, pass_demo 942, user_tokadd 793, mc_syntax_demo 733, user_dupop 693, user_param_nop 544, user_lit_nop 519, user_demo 478, user_dupty 400, user_dupintrin 388, mc_probe 328, mc_badmach 217, mc_lit_nop 173, mc_param_nop 17039 654
the #embed test payloads2embed_demo 641, embed_demo.txt 23664

Two facts worth naming before any design. The single largest row that leaves is user_syntax_demo, 21 005 B of blob -- a check-script fixture, not a library; and lz (src/lz.mc, 4 103 B) stays although its bundled name is flat, because src/core_min.mc includes lz.mc and the blob is what serves that relative include for <mc/core>. The rule is about the path, not the spelling, and lz is the row that proves it.

1.2 The three binaries, measured #

Measured by copying tools/bundle.list aside, writing the 60-row subset, running build/bundle, and building with build/mc1 --exe src/mc.mc; then restoring both files and confirming with cmp:

binary__text__cstring__DATA,__datablob
mc today1 408 576540 19231 024691 456678 368
mc with the 41 rows out (measured, not projected)1 268 276540 19231 024566 832555 053
mc-slim (no blob at all)626 420476 28027 2488 6640

1.3 The four probes: does the road that is already there actually serve a library? #

Run on this host with the thinned compiler of § 1.2 (/tmp/mc-thin), a private HOME, and no network. Transcript in the commit that lands step A; the four results:

probecommandresult
1thinned compiler, nothing installed, #include <sys>m52probe.mc:1: unknown bundled include: sys, exit 1
2unthinned build/mc1, same sourcecompiles, runs, exit 42
3mc install --from-tree . --yes into the private HOMEpackage mc 0.0.0-dev -> …/.mc/libs/mc/v0.0.0-dev/, exit 0, 0.28 s, 107 files, 3 308 KiB
4thinned compiler again, same source, single-file CLI, no mc.toml, no lockcompiles, runs, exit 42

Then the road a taught compiler takes, which is the consumer's road:

probecommandresult
Athinned compiler + installed tree builds #include <mc/host> + #include <mc/core> + #include <user_float>exit 0, 1 324 746 B
Bthat compiler compiles and runs a program using <sys> and <float_rt>prints 10.000, exit 42
Cthe same taught compiler built by the unthinned build/mc1exit 0
Dthe object each taught compiler writes for the same float programcmp equal

Probe D is the inertness proof of the whole milestone: a library's bytes are the same bytes whether the blob or the installed tree hands them over, so nothing the compiler emits can move. Probe 4 is the reason design (I) wins § 3: the single-file CLI has no lock road at all (../reference/packages.md § 2), so step 3 is the only road that can serve tests/float/*.mc, check-float, check-wide and a bare mc prog.mc -o prog.

1.4 What that decides #

  1. The size gain is real but modest (10%), and it is all data. The spec says so in its own headline instead of letting a reader expect a smaller compiler (§ 10.1).
  2. The mechanism is not to be designed -- it is to be reached. The compiler-side work is a predicate in the generator, two more roots in one function, and one widened message (§ 2, § 4).
  3. Because the spelling of every #include <name> is unchanged, the migration is not a source migration. It is a provisioning migration: the tree has to be somewhere. § 4 decides where.
  4. The 41 rows are not 41 libraries. 17 of them, and the largest single row, are check-script fixtures -- which is an argument against publishing them as packages and for keeping them files of the mc package (§ 3).

2. The essential set -- decision (design question 1) #

D1. A bundle row stays iff its path is under src/, plus exactly two named exceptions. One predicate, in tools/bundle.mc, ~8 lines:

in the blob  <=>  path starts with "src/"  or  name is "prelude"  or  name is "user_default"

The two exceptions are not taste, they are the compiler's own #includes: sixteen files under src/ include ../lib/prelude.mc and src/user.mc includes ../lib/user_default.mc (§ 0), and inside the blob those relative includes resolve by name to prelude and user_default (M15's last-component fallback). Drop either and <mc/core> is broken and check-standalone fails at once. They are "verdadeiramente dependência" in the owner's exact sense.

Three alternatives, and why not:

tools/bundle.list itself is unchanged -- all 101 rows stay, and that is load-bearing: the file is the NAME->PATH map step 3 reads out of the installed tree (dp_mc_load), so a row that left the blob must still be in the map or the library is unreachable by either road. The root mc.toml's 105 files are unchanged for the same reason. The blob shrinks; the package does not.

3. Where the libraries live -- decision (design question 2) #

D2. They stay files of the mc package and are served by resolution step 3. M52 publishes no new registry package. Proven by § 1.3, at a cost of zero new resolution roads.

The alternative -- one package per library (float, i128, f16, io, sys_linux, sys_windows, and rt/http/sqlite from examples/api), priced in M47-S5.md § 4 as ~150 changed lines plus the moves -- is refused for M52 on three measured grounds, not on cost:

  1. Only one bare name per package survives. libs_open (src/deps.mc:1313) matches a lock row by the name's FIRST path component and then joins either the rest of the path or, for a bare <geo>, the package's lib entry. So a package named float serves <float> and <float/machine_arm64_float.mc> -- but not <machine_arm64_float>. Every one of the 41 names except at most six would have to be respelled in every consumer, including the teko compiler, which includes <machine_x86_64_float> and <machine_arm64_float> by their bare names today (§ 7). Design (I) changes no spelling at all.
  2. The single-file CLI has no lock road, so tests/float/*.mc, tests/wide/*.mc, tests/windows/07*.mc, every <sys> program in bench/, and mc prog.mc -o prog itself could not resolve a package name without each becoming a project with an mc.toml. Probe 4 shows step 3 serving exactly those.
  3. 17 of the 41 rows are check fixtures (§ 1.1). user_syntax_demo, machine_probe, pass_demo, user_dupop are not a standard library; they are this repository's test corpus and they have no consumer and no version series. A package for them would be a published artefact nobody installs.

What D2 gives up, and what replaces it: a project cannot pin a newer float than its compiler ships. It can pin a newer everything -- [deps] mc = "0.17.0" is a legal row, mc is the registry's own published package and the tree is hashed and locked like any other -- and when per-library packages arrive (§ 9) the layering works with no change here, because step 1 precedes step 3: a [deps] float pin overrides the compiler's own copy by construction.

D3. The libraries are versioned with the compiler, because they are files of the mc package and mc install writes <libs>/mc/v<mc_version()>/. No second version series, no 1.0.0 freeze commitment for float, no lockstep to maintain. This is M48.md § 2's mclib rationale with the package it already had instead of a new one -- see § 9.1 for what exactly of M48 § 2 is superseded.

4. The library root, and the offline promise -- decision #

D4. dp_mc_load gains two more roots, tried after <libs>, derived from host_self_path(); and a release ships the tree beside the binary. ~15 lines in src/deps.mc, and it is what keeps the property M15 earned and ../bootstrap.md asserts: untar, compile a float program, no network.

Order, and why:

orderrootwho puts it there
1<libs>/mc/v<version>/ -- --libs-dir DIR or $HOME/.mc/libsmc install, mc upgrade
2<dir of host_self_path()>/lib/mc/v<version>/the release tarball, and make
3<dir of host_self_path()>/../lib/mc/v<version>/a packager who puts the binary in bin/

<libs> is first so that an explicit mc install still wins over the copy that shipped -- a user who installs a newer tree gets it, which is the same precedence --libs-dir has over $HOME today. Roots 2 and 3 are two lex_readable probes; root 3 exists because /usr/local/bin/mc has to find /usr/local/lib/mc/v0.16.0/, and a packager who cannot do that will vendor the blob back in.

The tree beside a binary is a partial tree and that is fine by construction: dp_mc_load reads bundle.list and dp_mc_open falls through on any name whose file is not readable (§ 0), so the release stages only bundle.list and the lib/ files -- about 312 KiB of source, and nothing of src/ (a full binary answers every mc/* name from its blob; a slim one needs mc install, exactly as today).

D5. make populates root 2 for the whole suite. Four lines in the Makefile put bundle.list and lib/ under build/lib/mc/v<version>/, beside build/mc1, and every check script then works with no new flag, no --libs-dir and no dependence on $HOME -- which is the CI rule (../reference/packages.md § 2's last paragraph) satisfied by the layout instead of by an argument. This is why --libs-dir is deliberately not added to the single-file CLI: nothing needs it once the root is beside the binary.

D6. The refusal is widened. dep_include_hint is asked only when bopen_fn == 0 today, so the thinned full binary of probe 1 says unknown bundled include: sys and names no road. After M52 a binary asks the hint whenever nothing answered, and the hint distinguishes three states:

statemessageexit
no root found at allprog.mc:1: #include <sys>: not in this compiler and mc 0.16.0's library tree was not found: run mc install1
a root found, the name is not in its mapprog.mc:1: unknown bundled include: sys (unchanged)1
a slim binary, no rootprog.mc:1: #include <prelude>: not bundled in this compiler and mc 0.16.0 is not installed: run mc install (unchanged)1

The middle row is the one that must not move: a misspelled name is still a misspelled name, and check-mc/check-surface assert that text.

5. Install on demand at a build -- decision (design question 3) #

The direction asks for libraries "que podem ser instaladas (caso não existam) no momento do build". Three roads were weighed against two promises -- mc build never downloads (../reference/packages.md § 9, proved by check-pkg's failing curl) and one source gives one answer on every machine (../determinism.md).

roaddeterminismoffline promiseverdict
(a) refuse, and name the commandkeptkeptthe default (D6)
(b) resolve the newest implicitly and write the lockbroken: the answer is a function of the day the build ranbroken silentlyrefused
(c) an explicit flag that runs the sync step firstkept -- sync writes the lock, and the lock is what the build readskept: without the flag, nothing reaches the networkD7

D7. mc build --sync lands, and it is the only road from a build to the network. It is pkg_sync(dir, yes) followed by the build, ~20 lines in src/driver.mc, no new resolution semantics, the same plan and the same [[permission]] consent mc pkg sync --yes already prints (../reference/packages.md § 10). Without the flag mc build is byte for byte the command it is today, and check-pkg's curl-that-exits-97 keeps proving it.

(b) is refused with a number: a project whose [deps] names no version would resolve float to whatever the index holds at that minute, so two machines and two days give two objects -- and every gate in this repository that says "the same source twice" would be measuring the registry.

What the common case does after D4 is reach the network not at all: the tree ships beside the binary, so --sync is for a project with real [deps] rows, which is what it was always for.

6. The compiler's own tests -- decision (design question 4) #

D8. make check runs with no network and no $HOME, through root 2 (D5). No vendored deps/, no local registry, no --libs-dir plumbing: one Makefile rule, and every script that reaches a library name keeps the name it has.

What each gate needs, read from the scripts (18 of them name a library path or a bundled library name):

gatewhat it does todayafter M52
check-float (16/16 on three run legs, 14/14 objects on two Windows legs)builds lib/mc_float.mc by path; tests/float/*.mc say #include <sys>, <float_rt>unchanged; the two names come from root 2
check-widelib/mc_i128.mc, lib/mc_u128.mc, lib/mc_f16.mc by path; tests/wide/*.mc say <sys>, <i128>, <u128>, <f16>unchanged, same reason
check-surfacebuilds taught compilers from lib/mc_syntax_demo.mc etc. by pathunchanged (paths, not names)
check-standalone#include <user_syntax_demo> and <syntax_demo_test> as bundled names (scripts/check-standalone.sh:61,65)the two names come from root 2; the <mc/host> + <mc/core> + <user_default> half is untouched and still cmp-identical to src/mc.mc
check-lex, check-ast, check-asmglob lib/*.mc and compare mc_seed against mc1unchanged: they read files, not names. Counts do not move
check-obj, check-inert, bootstrapcompare artefactsunchanged; probe D says why
check-bundlereproducible + fresh + the <mc/bundle_data> shape+1 case: the blob holds exactly the 60 rows the predicate admits, and every one of the other 41 resolves through root 2
test-linux, test-windows, test-windows-exegenerate a TOML per test; the sources say <sys>, <io>, <sys_linux>, <sys_windows>unchanged; the cross-built compiler on the macOS side reads root 2
check-mctests/mc/073-embed-bundle.mc uses #include <embed_demo> and #embed of <embed_demo.txt>the two payloads come from root 2
check-pkg (145/145)the curl/wget/tar shim, mc install --from-tree, § 32+1 section: root 2 and root 3 each serve a name the blob lacks, and with both removed the D6 message is exact
check-docscompiles every ```mc fencethe fences that say #include <sys> need root 2 on PATH-adjacent terms: one line in the script
check-partsthe six parts, <mc/core_min> alone, the slim assemblyunchanged

A new gate, scripts/check-libroot.sh (~90 lines, in make check), is the one thing D4 adds that nothing else covers: the three roots in precedence order, a partial tree, a name in the map whose file is absent (falls through, no crash), and the D6 message with every root removed.

7. The consumer -- decision (design question 5) #

teko reported its bundle surface on 2026-09-13, measured by grep over its own modules:

whatincludes from the bundle
the teko compiler (teko*.tk, core_teko.mc)<sys>, <mc/core_writers>, <mc/core_machines>, <mc/core_bundle>, <mc/core_build>, <machine_x86_64_float>, <machine_arm64_float>, <float>
lib/rt.tk, lib/time.tk<name> through <mc/core_bundle> only -- no <io>, no <i128>
teko_std<teko/lib/rt.tk> from the registry; zero direct bundle

D9. The 0.16.0 cut needs no change in teko at all, and no deprecation window. Every name in that table keeps its spelling: the four mc/* ones stay in the blob (§ 2), and <sys>, <float>, <machine_arm64_float> and <machine_x86_64_float> come from the library root the release ships (§ 4). The exact [deps] lines teko will need are: none.

That is the whole value of D2 over per-library packages, and it is worth stating as the counterfactual: under design (II) teko's compiler would need [deps] float = "0.16.0" plus three respellings -- <float/float.mc>, <float/machine_arm64_float.mc>, <float/machine_x86_64_float.mc> -- because only a package's lib keeps a bare name (§ 3.1), and its registry modules array would have to carry them ahead of <teko/teko.tk>.

teko's one open question -- "como um pacote de linguagem declara 'preciso do pacote float carregado antes dos meus módulos'" -- is answered today and needs no feature: [compiler].modules is an ARRAY and drv_gen_compiler emits one #include per entry in array order (§ 0), with a <...> value passed through verbatim. So the answer is one line ahead of the language module, whether the name is a bundled one now or a package one later:

[compiler]
modules = ["<user_float>", "<teko/teko.tk>", "<teko/user.mc>"]

D12 (step D, added 2026-09-14 from the consumer's dry run against main 62f0cdd/21d081b). The table above is about a program teko's compiler compiles. What it did not cover is teko's compiler itself: a [compiler] product is a second binary in the project's own build/, so roots 2 and 3 are relative to IT and the tree beside mc is invisible. Measured by the consumer: the product works when mc build spawns it (the parent hands its root over, deps_libs_for_child) and fails when run standalone -- build/teko --entry-only ..., its own bootstrap.sh, its check-docs.sh -- with lib/rt.tk:40: unknown bundled include: sys, 3 of 64 fixtures; --libs-dir or a copied tree gives 64/64 and FIXPOINT OK. Three shapes were weighed:

shapeverdict
(a) mc build STAGES the tree beside the [compiler] producttaken: a taught compiler leaves mc build as self-sufficient as the release tarball
(b) bake the builder's absolute root into the binaryrefused: a path in the object, against ../determinism.md
(c) --libs-dir on every invocationrefused: it pushes the contract onto every consumer's scripts, and breaks a user who is handed build/teko

So D9 stands for the SOURCES and gains one sentence for the BINARY: the consumer's --libs-dir workaround is no longer needed on any road.

D10. No deprecation window, and no alias. The roadmap's 1.0.0 policy (add the new, keep an alias for one major) is about a renamed or removed surface. Nothing here is renamed and nothing is removed: #include <float> resolves to the same bytes before and after, by a different road. A deprecation message would fire on a spelling that is still correct. What 0.16.0 owes the reader is a release note that says the library tree ships beside the binary and that a binary copied out of the tarball alone loses it (§ 13.2).

8. Goldens, the bootstrap and the flavours -- decision (design question 6) #

D11. All ten goldens move once per step that regenerates the blob, each only after its own criterion -- the empty --dump-asm diff on both roads and cmp mc2.o mc3.o / cmp mc2o.o mc3o.o for the two local ones, make check-linux-host for the Linux pairs, the cross-computation of ../../tests/golden/README.md for the Windows pairs. Steps A and B each regenerate it (A changes src/deps.mc, B changes the blob's contents), so the goldens are rewritten twice over the milestone, once per commit -- the M41 precedent, recorded so nobody reads a double rewrite as a mistake.

build/mc_seed is unaffected: src/mc_seed.mc has relative includes only and never reaches a bundle (§ 0). stage0/ is untouched, 2848/3000.

D12. Both flavours survive, and both ship the library root. After M52 the difference between them is exactly one sentence -- the full binary carries the compiler's own source and can therefore teach a compiler with no root at all; the slim binary carries none and needs the root for everything. The ten -slim release archives stay, scripts/release-assets.sh --slim stays, and the slim tarball becomes a complete offline toolchain for the first time: today it needs mc install before #include <prelude> works, and after D4 the root travels with it.

The measured pair to put in ../reference/bundle.md: full 1 268 276, slim 626 420, 49.4%.

9. Priced, not built #

9.1 What of M48.md § 2 is superseded, and what survives #

M48 § 2 designed mclib: lib/ as ONE registry package under lib/mc.toml, versioned with the compiler, published as a mc-libs-<ver>.tar.gz release asset, spelled <mclib/float.mc>, and explicitly not overriding the bare bundled names ("<float> still comes from the blob unless the project pins a package named float"). M52 keeps the goal and drops the package:

M48 § 2M52
a second package, mclib, with its own mc.toml, files, index row, admin registration and release assetnone. The libraries are already 41 of the mc package's 105 files (§ 0)
spelling <mclib/float.mc><float>, unchanged
<float> keeps coming from the blob<float> comes from the library root; the blob has only the compiler
§ 2.6's reassignment of mc-libs-<ver>.tar.gz and mc install --from-bundlesuperseded: mc install already fetches the mc row (that half of § 2.6 landed in M44 step 4) and D4 ships the tree in the binary's own tarball, so there is no second archive at all
§ 2.1's value (c), "a newer lib/ on an older compiler"deferred to § 9.2; [deps] mc = "<newer>" is the road that exists

M48 § 2's measurements and its road-A/road-B table stay useful and are cited by § 9.2. Its mclib name should be treated as retired, exactly as CLAUDE.md's C3 entry already records.

9.2 Per-library registry packages -- deferred, with the price and the one new fact #

M47-S5.md § 4 prices the moves (lib/float/, lib/i128/, …), what breaks (the lib/*.mc globs in three scripts, the paths check-float/check-wide/check-surface name, ~15 places in docs/), and answers the nesting question (lex_root_of keeps the LONGEST matching root, so an inner package wins -- untested, and a nested fixture in tests/pkg/ is the first thing that work should add). Two things to add to that page's price:

  1. The spelling cost, measured: 41 names, of which at most 6 could keep a bare spelling as some package's lib. The other 35 become <pack/file.mc> in every consumer, every test and every doc that names them (18 scripts, 25 docs/ files, § 6).
  2. The single-file road: a package name is unreachable without a project (§ 3.2), so the move turns tests/float/*.mc, tests/wide/*.mc and the <sys> programs of bench/ into projects, or requires a new resolution road for them.

When it happens, the shape is one package per directory under lib/<name>/, [package] name/lib/ files/mc/licence, [[permission]] empty (a library asks for nothing -- it runs inside the caller's program, which is what packages.md § 3 already says of a library's permissions), versions starting at the compiler's own minor, and the names registered by the owner first: float, io, sys, i128, f16, prelude are not in dep_reserved's set (§ 0) and any account can take them today. That is a registry action, not a compiler one, and it is the one item of § 9.2 worth doing before 0.16.0 ships (§ 13.4).

9.3 M51's <http> #

D13. M51 ships http as a registry package, never as a bundle row. The direction's first clause -- "M51 não violaria se fosse lib, não bundle" -- is settled by § 2's predicate: lib/http.mc would have a lib/ path and would therefore not be in the blob, so the question is only whether it is a file of the mc package or a package of its own. It is a package of its own, because HTTP is not a dependency of the compiler and its release cadence is not the compiler's -- which is the owner's rule applied to the one library that has a consumer outside this repository. M51 is therefore the first package of § 9.2, and it inherits § 9.2's two costs: bench/http/'s single-file programs (forkka.mc 53 lines, fork1.mc 50, httpmin.mc 124) become a project with an mc.toml, and the name http wants registering before the first tag. examples/api/lib/http.mc (253 lines) and rt.mc (253) are where the shape comes from; bench/http/mc/forkka.mc is the shape M51 packages.

10. Acceptance (runnable; numbers, not adjectives) #

  1. Size, this host, build/mc1 --exe src/mc.mc: 1 268 276 B (±0 -- it was measured, not projected), __DATA,__data 566 832, blob 555 053, and __text exactly 540 192, byte for byte unchanged. size -m in the commit. The blob holds 60 of 101 rows.
  2. Nothing the compiler emits moves. check-obj 32/32 against the frozen seed; check-inert identical on both roads for the 33 objects and all five taught examples; the --dump-asm diff between mc1 and mc2 empty on both roads; check-lex/check-ast/check-asm at their current counts with no new seed-skip; git diff --stat stage0/ empty.
  3. Offline. make check green end to end with no network reachable, and check-pkg's curl/wget/tar shim (exit 97 if invoked) unchanged and still not invoked.
  4. check-standalone: <mc/host> + <mc/core> + <user_default> still compiles to an object cmp-identical to build/mc1 src/mc.mc, and the taught half still builds from <user_syntax_demo> and compiles <syntax_demo_test> to exit 42 -- now through root 2.
  5. Every library leg unchanged in outcome: check-float 16/16 on macos/aarch64, linux/aarch64 and linux/x86_64 and 14/14 objects on each Windows leg, with the four llvm-mc sweeps at 60/60/249/232 distinct instructions and 0 mismatches; check-wide ok; check-surface 32/32 plus every M21/M24/M31/M41.5 case; test-linux and test-windows at their counts; check-mc at its count with 073-embed-bundle among them.
  6. check-libroot (new): root 1 beats root 2 beats root 3; a partial tree serves what it has and falls through for what it does not; with all three absent, the D6 message is exact, exit 1.
  7. The consumer, measured as a probe and not asserted: a program that says #include <sys> and a taught compiler that says #include <user_float> both build from a release-shaped directory (mc beside lib/mc/v<ver>/) with $HOME unset and no network; and the object the taught compiler writes is cmp-identical to the one today's mc writes (probe D).
  8. mc build --sync: with a [deps] row and nothing fetched, mc build alone is mc: float 0.16.0 is not fetched + the run: line, exit 2, and touches no network; mc build --sync fetches, writes the lock and builds. Asserted in check-pkg against the directory registry it already builds.
  9. check-limits 17/17 under 90%, globals unchanged (every new field goes in dp, § 12), and mc limits src/mc.mc in the commit.
  10. Ten goldens rewritten once per step, each after its own criterion (D11); make check-linux-host RC 0 over all four cells with the cross proof.
  11. Docs with check-docs green: bundle.md (the catalogue split in two, the new size table), packages.md § 2 (the three roots), cli.md (--sync), diagnostics.md (the widened hint), bootstrap.md (what "the binary alone is the toolchain" means after M52), build.md, guide/00-getting-started.md, guide/80-footprint.md, plan.md, CLAUDE.md § State; every

11. Steps -- gated commits #

orderstepcontentlines (src/ code, est.)gaingate
1A the library root and the refusal — LANDEDD4's two extra roots in dp_mc_load; D6's widened dep_include_hint and its three states; D5's Makefile rule; scripts/release-assets.sh stages lib/mc/v<ver>/; scripts/check-libroot.sh; docs86 added, 37 code (est. ~40)none by design -- the blob still has all 101 rows, so every name still resolves from it; the new road is proved by asking for a name the blob lacks§ 10.2, § 10.6, ten goldens once
2B the cut — LANDED§ 2's predicate in tools/bundle.mc; make bundle; check-bundle's 60-row case; the check-pkg section; docs' catalogue split — plus the two roads a library name could not reach any more, § Implementation notes 4 and 552 code lines in src/ (est. ~0: see notes 4 and 5), 8 in tools/bundle.mc−132 098 B measured, −9.4% of the binary and −18.0% of __DATA,__data, __text unchanged to the byteall of § 10, ten goldens once
3C mc build --sync — LANDEDD7: the sync step before the build, the two flags, and the pointer that keeps the driver free of the package half. The run: line is unchanged — see § Implementation notes -- step C, 579 added, 30 code (est. ~20)the direction's "instaladas no momento do build", explicitly§ 10.8, check-pkg § 38, check-parts § 4c, ten goldens once
4E the child's roots and the symlink — LANDEDD12's two follow-ups from the consumer's dry run: mc build forwards --libs-dir only when it was given one (a derived root made the child look for the entry's [deps] under the mc tree), and host_self_path() resolves a symlink on macOS — see § Implementation notes -- step E44 added / 35 removed, code +4 / -7 -- net 3 code lines REMOVED (deps.mc +14/-21, driver.mc +12/-10, host_macos.mc +18/-4)a [compiler] project with [deps] builds with no flag; ~/bin/mc -> build/mc1 finds the treecheck-pkg § 39, check-libroot (h), ten goldens once
--D per-library packages, http first§ 9.2, § 9.3~0 here; the moves and the registry rowsa project can pin a newer library than its compilerdeferred: M51 owns the first one

Needed for the 0.16.0 cut: A and B. C is the direction's own clause and landed with them. D is M51's.

12. Files and estimated deltas #

filelineswhat
src/deps.mc+40 (~30 code)D4's roots 2 and 3 in dp_mc_load (host_self_path() + two lex_readable probes, the chosen root in DP_MCDIR as today); D6's three-state dep_include_hint. No new global -- the state is already dp's
src/lex.mc+6lex_include_name asks the hint whenever nothing answered, not only when bopen_fn == 0
src/driver.mc+20D7's --sync
src/core_build.mc+1the usage line
tools/bundle.mc+15§ 2's predicate, with the two exceptions named in a comment
tools/bundle.list, mc.toml0§ 2's last paragraph: the map and the package keep all 101/105 rows
Makefile+10D5's build/lib/mc/v<ver>/; check-libroot in check and .PHONY
scripts/check-libroot.sh~90new
scripts/check-bundle.sh+25the 60-row case; every dropped name reachable through root 2
scripts/check-pkg.sh+40the three roots, the D6 message, --sync against the directory registry
scripts/check-docs.sh+3the root for fences that include a library
scripts/release-assets.sh+30stage bundle.list + lib/ under lib/mc/v<ver>/ in both flavours' tarballs; INSTALL.txt
.github/workflows/release.yml+0the tarballs are built by the script the jobs already call
docs/~220§ 10.11
src/bundle_data.mc, ten goldensregeneratedtwice, once per step (D11)

Net new src/ code: about 55 lines. stage0/: 0. That number is the milestone's own argument: the mechanism was built in M44 and this spec mostly reaches it.

13. Risks #

  1. The consumer. Measured at zero include rewrites (§ 7) and zero [deps] lines. The residual is provisioning: if teko's CI copies the mc binary out of a tarball and discards the rest, <float> stops resolving. Named in the release note, and check-libroot is the gate that keeps the three roots working.
  2. "The binary alone is the toolchain" is weakened for library names. This is real and it is the price of the direction: before M52 a copied binary compiled a float program; after it, the binary plus the lib/ directory beside it does. docs/bootstrap.md's claim must be rewritten to say the compiler's own source is inside the binary -- which is still the strong claim, and the one check-standalone proves. A user who wants the old behaviour has mc install and a project has [deps] mc.
  3. The library root is unverified. Step 3 hashes nothing (§ 0), so a tampered lib/ beside the binary is compiled without complaint, where the blob was inside a signed executable. Exactly as true of mc-slim today, which shipped. The answer for a consumer who needs verification is the lock road -- [deps] + mc.lock, hashed per file -- and a hash check on root 2/3 is priced as a follow-up (a v<ver>.toml cache manifest already exists for mc install's road; the beside-the- binary road would need one written at release time).
  4. Name squatting. float, io, sys, i128, f16, prelude, http are not reserved (dep_reserved, § 0) and § 9.2/§ 9.3 will want them. Registering them before 0.16.0 is a registry action with no compiler cost, and it is the cheapest item on this page.
  5. Registry availability at build time is a risk only for mc install on a slim binary and for D7's --sync. mc build without the flag still never calls out (§ 5), so a registry outage cannot stop a build of a locked or vendored project.
  6. CI minutes for per-package repositories -- deferred with § 9.2. One publish-to-registry job per package is the shape; M48 § 2.2's road-A table is the price.
  7. The other 476 KB is code, and this milestone does not reach it. § 1.2 measures where it is (__text 540 192, of which 63 912 is <mc/core_bundle> + <mc/core_sandbox>). If the owner wants the binary leaner than 1.27 MB the lever is the flavour, not the blob, and the question is whether mc sandbox belongs in the default compiler -- M43's call, not M52's.
  8. Two golden rewrites in one milestone (D11) read like a mistake in a log. Recorded in the step table and in CLAUDE.md § State.
  9. A future src/ file that should NOT be in the blob, or a lib/ file that should be, would make § 2's path rule wrong. The escape is the third column § 2 priced and refused; it is 10 lines whenever the first counter-example arrives.
  10. check-docs fences. Six docs/ pages have ```mc fences that include a library name; if the root is not visible to the script, the gate fails loudly rather than silently, which is the behaviour wanted.

14. Decisions #

decisionwhy
D1A bundle row stays iff its path is under src/, plus prelude and user_default; the predicate lives in tools/bundle.mc; tools/bundle.list and mc.toml are unchangedthe owner's rule stated once and applied per row; the two exceptions are the compiler's own #includes (16 files and src/user.mc), and lz proves the rule is about the path and not the spelling
D2The libraries stay files of the mc package and are served by resolution step 3. M52 publishes no new registry packagethe road exists and was proven end to end (§ 1.3); no #include spelling changes; a package would keep only ONE bare name and the single-file CLI has no lock road; 17 of the 41 rows are check fixtures
D3The libraries carry the compiler's versionthey are files of <libs>/mc/v<mc_version()>/; no second series, no lockstep
D4dp_mc_load gains <exe>/lib/mc/v<ver>/ and <exe>/../lib/mc/v<ver>/ after <libs>, and a release ships that treekeeps "untar and a float program compiles, offline"; <libs> first so an explicit mc install still wins; a partial tree is safe because step 3 falls through
D5make populates root 2 beside build/mc1every check script keeps working with no flag and no $HOME; this is why --libs-dir is NOT added to the single-file CLI
D6dep_include_hint is asked whenever nothing answered, and distinguishes three statesthe thinned full binary must not say unknown bundled include and name no road; a misspelled name must keep the message the gates assert
D7mc build --sync is the only road from a build to the network; implicit newest-resolution is refuseddeterminism: an implicit resolution makes the object a function of the day; sync writes the lock and the lock is what a build reads
D8make check stays offline through root 2 -- no vendored deps/, no local registry for the librariesone Makefile rule against 18 scripts of plumbing
D9No [deps] line and no respelling for teko; the cut is source-compatiblemeasured from teko's own grep (§ 7); the counterfactual under per-library packages is three respellings and a [deps] row
D10No deprecation window and no aliasnothing is renamed or removed; a deprecation message would fire on a spelling that is still correct. The release note carries the provisioning change instead
D11Ten goldens, rewritten once per step (twice over the milestone), each after its own criterion; mc_seed unaffectedsteps A and B both change what goes into src/mc.mc
D12Both flavours survive and both ship the root; the ten -slim archives and --slim stayafter M52 they differ by exactly one sentence, and the slim tarball becomes offline-complete for the first time
D13M51's http is a registry package, the first of § 9.2HTTP is not a dependency of the compiler and its cadence is not the compiler's
D14mclib (M48.md § 2) is retired, not implementedthe mc package already carries lib/; a second package would add an index row, an asset, an admin registration and a second spelling for nothing (§ 9.1)

15. The 0.16.0 cut #

The CUT is coordinated with teko; the design is not. What each side does:

here, before the tagsteps A, B, C merged, make check and make check-linux-host green, the ten goldens re-recorded, docs/ and CLAUDE.md § State updated; the release note's provisioning paragraph (§ 13.2); the registry names of § 13.4 registered
here, at the tagrelease.yml produces the ten archives as it does today, each now carrying lib/mc/v0.16.0/ beside the binary
teko, before the tagnothing to change. A dry run of its own recipe against a 0.16.0 pre-release binary in a directory shaped like the tarball, which is the one thing that can go wrong (§ 13.1)
teko, after the tagMC_VERSION to 0.16.0 in its own crumb, as it does for any minor
registry, before the tagMC_VERSION raised from 0.15.20 to 0.16.0 (§ 16) -- a manifest naming [package].mc or a version constraint (= ~ ^ * >=) is refused by the older validator

M52 is a minor and not a major because no source stops compiling: § 7's table is the evidence, and D10 is its consequence.

16. The registry side #

minicompiler/mc-registry carries docs/spec-M52-registry.md, written 2026-09-07 from an earlier sketch of this milestone (a minicompiler/stdlib repository publishing a stdlib package in lockstep with the compiler). Its opening was aligned to THIS spec on 2026-09-14 (minicompiler/mc-registry PR #29): the design of record is D2 above -- the libraries stay files of the mc package, served from lib/mc/v<ver>/ beside the binary. No stdlib package and no minicompiler/stdlib repository exist in 0.16.0; D14 retires mclib.

What the registry's step S0 (minicompiler/mc-registry PR #20) built stands under the new design: pkg_reserved_exact gained stdlib and std, and jb_reserved_by_mc mirrors this compiler's dep_reserved (mc, deps, build) independently, on the registry's own side of the wire. Both are harmless under D2 -- nothing publishes either name -- and they keep the deferred per-library road (§ 9.2, http first) open without a second reservation pass later.

Risk 4's names are being reserved on the registry as step S1 (minicompiler/mc-registry, in flight as this section lands): float, io, sys, i128, u128, f16, prelude, http join pkg_reserved_exact, so a public registration of any of them is refused while the admin road still accepts one. The compiler's own dep_reserved (§ 0) is UNCHANGED by S1 -- a consumer still has to be able to pin a future http package ([deps] http = "1.0.0"), and reserving a name only against PUBLIC self-registration is exactly what that needs.

The cut checklist (§ 15, "here, before the tag") gains one operational row: the registry's own pinned MC_VERSION (a Dockerfile ARG, 0.15.20 at the time of writing, kept in step across six spellings by its own scripts/check-version.sh) has to be raised to 0.16.0 BEFORE any consumer publishes a manifest that says [package].mc (this compiler at 0.15.27) or writes a version constraint (= ~ ^ * >=, this compiler at 0.15.28) -- the 0.15.20 validator does not know either spelling and refuses both.

Implementation notes -- step A #

Written after the fact, against the tree that landed. The design above is unchanged; what follows is what the code and the gates actually do, and the four places the text and the tree had to be reconciled.

  1. The three roots are one function, dp_mc_root() in src/deps.mc, and dp_mc_load calls it where it used to build <libs>'s path inline. A root is recognised by its bundle.list and by nothing else: the first root that HAS that file is the only one read, so a <libs> tree that exists but is empty does not silently fall through to the one beside the binary and hand a reader a library he did not install. Roots 2 and 3 come from path_join(host_self_path(), ...) -- path_join cuts its base at the last slash, which is exactly "the directory of this file", and normalises the .. of root 3 lexically.
  1. A self path with no slash in it is refused. host_self_path() is _NSGetExecutablePath / readlink /proc/self/exe / GetModuleFileNameA and all three normally answer a path with a directory in it, but path_join with a base that has none answers a RELATIVE path, which would make the root a function of the working directory. Four lines say no instead (docs/determinism.md: one source, one answer, wherever mc was run from).
  1. D6 reads bopen_fn. dep_include_hint has to tell "no root and no blob" (mc-slim, the unchanged sentence) from "no root and a blob" (the new one), and whether this binary carries a blob is one global in src/lex.mc, set by mc_bundle_init(). src/deps.mc already calls lex_readable, and it is compiled after src/lex.mc in every assembly that contains it (<mc/core_build> stands on <mc/core_min>), so the read costs nothing and no argument was added to the hook.
  1. scripts/libroot.sh is the one definition of the tree and both the Makefile and scripts/release-assets.sh call it. Writing the copy twice was the alternative and it is the one drift nobody would notice: a release whose library root is laid differently from the one every gate runs against is a release nothing tested.
  1. scripts/check-standalone.sh case 5 changed, and that is D6 working rather than a regression. That script copies the binary into an empty directory and runs it there -- no tree beside it, and HOME is now an empty directory too, on purpose, so the answer does not depend on whoever ran mc install on the machine. It is therefore exactly state 1 of § 4's table, and it now asserts the sentence that names the road. The middle sentence, unknown bundled include, is asserted twice in scripts/check-pkg.sh (both with the full binary in build/, which has root 2 beside it) and once more in scripts/check-libroot.sh case (e).
  1. The gate proves which road answered, with a name the blob does not have. In step A the blob still carries all 101 rows, so any real name resolves at step 2 and a test that included <sys> would have passed with the roots deleted. Every positive case in scripts/check-libroot.sh therefore adds one row to the tree under test -- m52probe, three lines of mc returning 42 -- and compiles, links and RUNS a program that calls it. Case (d) gives the two trees different bodies (42 and 7) and reads the precedence off the exit code.
  1. Measured teeth. Run against a build/mc1 built from main (the commit this branch forked from), scripts/check-libroot.sh reports 3/7 with four failures -- the refusal, root 2, root 3 and the unpacked tarball, each unknown bundled include: m52probe -- and exit 1. The three that pass are root 1 (which M44 already implemented), the middle message and the tarball's file count (scripts/release-assets.sh is this branch's).
  1. Deviation, on record: case (f) compiles <m52probe> and not <float>. § 10.7 asks for a <float> program out of a release-shaped directory; lib/float.mc is a compiler MODULE (type_new, intrinsic, a syntax_lit handler) and compiling it as a program is call to unknown function, so it needs a taught compiler built first -- minutes, in a gate that runs in seconds. What case (f) does instead is stronger about the root and weaker about the library: it untars the archive scripts/release-assets.sh writes, asserts that lib/mc/v<ver>/ came out of it with bundle.list and all 41 library files, adds the probe row to the UNPACKED copy and compiles a program through it with an empty $HOME, no --libs-dir and no network. Step B, where <float> LEAVES the blob, is where the literal form of § 10.7 becomes measurable, and scripts/check-float.sh is the gate that will see it.
  1. What did not need a line. lex_include_name's three steps, libs_open, dp_mc_open, mc install, the cache manifest and the closure rule are all untouched: a root is a root whichever directory it was found in, and the only thing M52 step A changed about step 3 is WHERE the search starts.

Implementation notes -- step B #

Written after the fact. The predicate and the sizes are the design's; what follows is the two places the design did not look, and the measurements that settled everything else.

  1. The predicate is bl_in_blob + bl_cut in tools/bundle.mc (~45 lines with the comment, 8 of code). bl_verify still runs over all 101 rows -- sorted, unique, no mc/bundle_data, no two sharing a last component -- and only then does bl_cut compact the arrays to the 60 the predicate admits. So the manifest's reviewability invariants keep covering the whole map, which is what the map has to be: a row that left the blob is still the only thing that turns <sys> into lib/sys.mc for the tree. bl_report gained one line (if (pad < 1) pad = 1;): a name past the 20th column used to be glued to its byte count, and check-bundle parses that report now.
  1. scripts/libroot.sh stages every row whose path is not under src/ -- 43 files: the 41 the blob lost, plus prelude and user_default, which are lib/ paths and ride along for free (the blob answers them first and the copies are never read). It gained a --libs DIR mode that lays the same tree at DIR/mc/v<ver>/ instead of DIR/lib/mc/v<ver>/ -- that is resolution root 1, what mc install --libs-dir DIR writes, and three gates need a <libs> that is whole and not just a directory of packages.
  1. check-bundle compares the generator against the manifest, then the tree against the compiler. The first half is a diff of the 60 names tools/bundle.mc reported against the predicate restated over tools/bundle.list in awk, so a row that moved in either direction is a named diff; the <mc/bundle_data> shape check's INT count comes from the same helper (240 values, four per blob row, not per manifest row). The second half offers each of the other 41 names to the real compiler, which has root 2 beside it, and requires that neither refusal appears -- most of those files are compiler modules that mean nothing on their own, so resolving and compiling are different questions and only the first is asked. Then the same compiler copied into an empty directory refuses <sys> with the sentence that names mc install: the literal form of § 10.7, which step A could only prove with a synthetic row.
  1. Deviation: src/ was touched, and the cut is why. mc build with a [compiler] section writes a taught compiler into the project's own build/ directory and spawns it there, so roots 2 and 3 -- both relative to host_self_path() -- are invisible to it. Measured on the unmodified tree: mc build examples/desktop --config ui.toml answered main.ui:16: #include <sys>: not in this compiler and mc 0.0.0-dev's library tree was not found: run mc install, with the tree sitting beside build/mc1 all along. That is a regression for every user with a [compiler] and a library include, not a test-plumbing problem, so it is fixed at the root: deps_libs_for_child() (src/deps.mc) answers the <libs> a child must be told about -- 0 when --libs-dir was given (already forwarded) and 0 when root 1 answered (the child inherits HOME), else the tree beside THIS binary -- and drv_teach passes it as --libs-dir. dp_mc_root was refactored into dp_beside_libs + dp_root_in so that "beside the binary" has one definition. 22 added code lines (src/deps.mc 18, src/driver.mc 4), no new global (mc limits src/mc.mc reports globals 446/512 before and after). The two-step road -- mc build --compiler-only, then that compiler with --entry-only -- forwards nothing by construction, and there the caller names --libs-dir itself: scripts/check-opt.sh and scripts/check-inert.sh do, and docs/build.md § M52 says so for a user's own script.
  1. Deviation: the sandbox had to bring the tree into the box. With the cut, make test-sandbox came back 51 ok, 22 failed: every case whose program says #include <sys> (refused: syscall 78 (readlinkat), the compiler asking where its own binary is). Inside the box neither beside-the-binary root can work -- /mc is a single bound FILE and the box has no /proc -- so the box mounts the tree where <libs> looks: HOME is /src, hence /src/.mc/libs/mc/v<ver>/, bound read-only on the overlay's upper layer, granted read-only by Landlock (a mount of its own, so the /src rule does not reach it), with the host path resolved before the unshare in dp_mc_root's order and without naming one of its functions -- src/sandbox.mc is <mc/core_sandbox>, which check-parts builds on <mc/core_min> alone. 30 added code lines (src/sandbox.mc 20, src/sandbox_box.mc 9, src/seccomp.mc 1), one appended field in sb_state (SB_LIB, so no offset moved), no new global. On the overlay fallback road /src is a read-only bind and there is nowhere to make the mount point: the field is cleared and a library name is refused with the sentence that names mc install, which is the truth about that box. Result: 73 ok, 0 failed, 1 skipped. readlinkat also joined the four compile profiles -- measured with sh scripts/sandbox-trace.sh --union on Ubuntu 26.04 (aarch64, glibc 2.43, the Lima cell) and on Alpine 3 (aarch64, musl), both of which answer readlinkat; the trace runs OUTSIDE the box, where the compiler does take that road. The two x86-64 rows are the same name, unmeasured from this Mac (strace decodes nothing under its amd64 emulation), and the one residual is on record in docs/reference/sandbox.md § The profiles: musl on x86-64 implements readlink() with the readlink syscall, which no SN_* name covers, so on such a host a box with no tree reports refused: syscall 89 (readlink) instead of the sentence -- one --union run there fixes it, and it changes nothing for a box that has a tree.
  1. check-docs needed no line, against the step table's guess: the fences are compiled by build/mc1, which has root 2 beside it, and the taught=DIR fences go through mc build, which forwards it (note 4). 52 samples, 514 links, green with no edit.
  1. check-pkg grew a section and lost an assumption. --libs-dir REPLACES <libs>, so every hand-laid fixture <libs> is now laid whole (libroot.sh --libs, once each, at the top of the script): without it the taught compilers of tests/pkg/app and app-float, which both say #include <sys>, have no road to a library at all. Two assertions moved with it, and both are sharper for it: § 15's bundle-less probe wants a <libs> with nothing in it and now names $tmp/none, because a probe that found a partial tree would get unknown bundled include (the middle state of D6) instead of the slim sentence; and § 18's "nothing was fetched" test now says "nothing but the mc tree", which is what it always meant. The new § 37 is the D6 table on a real name: roots 2 and 3 each compile, link and RUN a program that says #include <sys> (exit 42, lib on stdout), and with neither root the refusal is exact. 186/186.
  1. check-standalone is where the scope of "alone" is now written down. The copied binary gets the library tree beside it, exactly as a release tarball carries it, so cases 1-3 (<sys> + <prelude>, a taught compiler from <mc/core> + <user_syntax_demo>, <syntax_demo_test>) pass through root 2; case 4 -- <mc/host> + <mc/core> + <user_default> compiled to an object cmp-identical to src/mc.mc's -- is untouched, which is the statement that matters: the compiler's own source is inside the binary. Case 5 needed a compiler with no tree anywhere near it and runs alone/bin/mc, two levels down, so that neither root 2 (its own directory) nor root 3 (one above) finds the tree in $tmp.
  1. Measured sizes. The cut in isolation -- the predicate and nothing else, against the tree this branch forked from:

    binary__text__cstring__DATA,__datablob
    all 101 rows1 400 552540 92431 111692 576678 368
    60 rows1 268 454540 92431 111567 952556 168

    −132 098 B, −9.4% and −18.0% of __DATA,__data, with __text and __cstring byte-identical -- § 1.2 predicted −140 300 against a tree that has grown since, and the shape is exactly what it said. The branch's own final binary is 1 285 334 B (__text 542 272, +1 348 for the nine new functions of notes 4 and 5; __DATA,__data 570 352; blob 558 552).

  1. Nothing the compiler emits moved. scripts/check-inert.sh against a build/mc1 built from the fork point: 33 objects identical on the plain road and 33 with --opt=1 (tests/*.mc and src/mc.mc), and byte-identical artefacts for examples/api, lang, conc, desktop and kernel through the taught compiler each side builds. The compiler's OWN body is confined too: compiling the fork point's src/mc.mc and this tree's with the same compiler gives 9 added function labels, 0 removed, and 12 of 1935 shared functions differing -- the six that were edited (dp_mc_root, drv_teach, sb_go, sb_build_tree, sb_landlock_apply, sb_rec) and the six bundle_* readers, whose immediates are the blob's own count going from 101 to 60.
  1. A foreign host needed one more line, and make check-linux-host is what found it. A Linux or Windows cell starts from a build/ that holds the compiler and nothing else -- scripts/check-linux-host.sh untars the checkout EXCLUDING build/, and a CI leg links the object it was handed -- so the tree has to be laid there before the suite runs: 12 FAILs per cell, all of them tests/mc/09[7-9]/10[01] (the M49 tests, the only ones in that corpus that say #include <sys>). libroot is now the first prerequisite of the Linux and Windows check subsets, and scripts/bootstrap-linux.sh / scripts/bootstrap-windows.sh lay it themselves right before the suite -- which also covers the gnu cell and CI, neither of which goes through make check for the chain. The chain above it needs nothing: src/mc_linux.mc and src/mc_windows.mc have relative includes only.
  1. Roots 2 and 3 never worked on a Windows host, and the cut is what exposed it. host_self_path() there is GetModuleFileNameA, which answers D:\a\mc\mc\build\mc.exe -- and every path function in this compiler cuts on / alone (path_join, path_norm, dep_in), so a backslash path has no directory as far as they are concerned and step A's own no-slash guard refused it. While the blob carried every name nothing ever asked, so the two CI legs were green through step A; with the cut they answered #include <backend_arm64>: not in this compiler and mc 0.0.0-dev's library tree was not found for all 41 names, with the tree beside the binary. src/host_windows.mc now hands back one spelling -- forward slashes, which Win32 takes in every call -- so the whole compiler reads one kind of path and mc upgrade/mc tool, the other two readers, get it too. The loop is the core loop/break form and not while: that file is compiled on its own by check-asm, with no <prelude> in scope.
  1. Two more places a fresh checkout needed the tree, both found by CI and neither reachable from a developer's machine, where build/ is never empty: check-bundle names the FILE target build/mc1 while $(LIBROOT) hung off the PHONY mc1, so on a clean build/ the tree was never laid (it is an order-only prerequisite of build/mc1 now -- a newer bundle.list re-lays the tree without relinking the compiler); and the two sandbox CI jobs download the compilers into build/ and run neither make check nor a bootstrap script, so scripts/ci-sandbox-cell.sh lays it before the cell.
  1. readlink is not readlinkat, and only the CI cell could say so. § 5's residual -- "musl on x86-64 implements readlink() with the readlink syscall, which no SN_* name covers" -- was measured on the sandbox linux/x86_64 job and is wider than it was written: glibc 2.39 on x86-64 uses readlink too, so sh scripts/sandbox-trace.sh --check there failed with src/sysno.mc has no index for: SN_READLINK. SN_READLINK is index 95 now (SN_COUNT 96), 89 on x86-64 and SN_ABSENT on AArch64, which has no such syscall at all; both x86-64 compile lists carry the row beside readlinkat, and the AArch64 pair carry readlinkat alone -- which is exactly what each host's own trace records. The residual is gone: a box with no tree now reports the sentence that names mc install on every cell.

Implementation notes -- step C #

Written after the fact. The flag is the design's; what follows is the one place the design did not look, the one number pkg_sync could not answer on its own, and what the gates measure.

  1. The driver may not NAME pkg_sync. src/driver.mc is <mc/core_build> and src/pkg.mc is <mc/core_pkg>, and check-parts § 1b requires each part to compile on <mc/core_min> alone -- so a direct call would have made the driver drag the fetcher, the registry and the lock writer into every compiler that has mc build, which is exactly the split docs/reference/packages.md § 9 rests on ("a compiler assembled without <mc/core_pkg> cannot download even in principle"). The sync step therefore arrives as a pointer, drv_set_sync(&pkg_sync_for_build) from mc_pkg_init() -- the shape lex_set_bundle and lex_set_libs already have. It is a field of the driver's own record (DRV_SYNCFN), not a global, so the globals row did not move. Measured: --dump-syms of a compiler assembled from <mc/core_min> + <mc/core_machines> + <mc/core_writers> + <mc/core_build> has _drv_build and no _pkg_sync, and mc build DIR --sync on it is mc: --sync needs the package half of this compiler: mc pkg is not in it, exit 1 (scripts/check-parts.sh § 4c).
  1. pkg_sync returns 0 for two different outcomes, and a build has to tell them apart. For mc pkg sync "the lock is written" and "I printed a plan and did not run it" are the same answer -- it said what it would do -- and both are exit 0. For a build they are not: in the second case the lock was not rewritten, so the build would either read a stale one or refuse. The wrapper pkg_sync_for_build(dir, cfg, yes) is what adds that one bit, and it adds nothing else: pkg_open_config + pk_set_yes + the same pkg_sync, then if (!pk_yes() && (pk_nplan() > 0 || pkg_perm_ask())) return -1;. -1 is "stop, exit 0": the plan is on the screen and nothing else happened. The permission half is not decoration -- a build that walked past an unaccepted [[permission]] set would be a second road around the consent mc pkg sync asks for.
  1. The flag is not forwarded to the --entry-only child, and that is by construction rather than by a line. drv_teach writes the child's argv name by name (build, the directory, --config, the config, --entry-only, then --libs-dir/--opt=/--limits when they apply), so a flag it does not mention cannot travel. The gate measures the consequence instead of the code: tests/pkg/sync/teach.toml is the same project with a [compiler] section, and mc build --sync --yes on it prints one lock line and two fetch rows, not two and four. A second sync in the child would have rewritten the lock a second time and said so.
  1. mc build has no --registry, and does not get one. The registry is [registry].url or the default. A build whose answer depended on a registry named in the shell line would be a build two people cannot reproduce from the same checkout, which is the same argument § 5 used to refuse implicit resolution. scripts/check-pkg.sh § 38 names its fixture registry by sed-ing a [registry] table into a copy of the project's config, which is what a private tap looks like in real life.
  1. The run: line did not change, against the step table's "the run: line it makes unnecessary" read as a licence to rewrite it. mc: mc.lock is stale and mc: <pack> <ver> is not fetched still say run: mc pkg sync --yes, for two reasons: the refusal is raised by src/deps.mc, which is <mc/core_build> and has no business naming a flag that needs the other part; and the advice is right on every road -- mc pkg sync --yes works whether or not the reader is inside a mc build. What --sync makes unnecessary is the second COMMAND, not the sentence. check-mc and check-pkg assert that text and neither moved.
  1. --yes on its own is refused, not ignored (mc: --yes applies to --sync: mc build downloads nothing without it) -- the post-M42 rule for a flag read by nobody. Both flags live in ONE record field, DRV_SYNC (0, 1, 2), so drv_run needed no new parameter and its three callers (drv_build, drv_limits, sysroot_cmd) were not touched; mc limits and mc sysroot stub parse no --sync and read 0.
  1. A part-less compiler still ADVERTISES the flag. The usage line is subcommand("build", ...)'s, registered by <mc/core_build>: a subcommand disappears with its part (M43 § 4b, mc sandbox), a flag inside one cannot. The refusal is what tells the truth, and it names the missing half. Re-registering build from mc_pkg_init() with a longer usage string was the alternative and it puts the same text in two files -- the one drift nobody would notice.
  1. A no-op sync stays a sync. mc build --sync on a project with no [deps] and no [tools] is sync: no dependencies, an empty mc.lock, and the build: that is what pkg_sync does there, and making the build skip the step would have been a second behaviour to remember.
  1. Cost: 79 added lines in src/, 30 of them neither comment nor blank (src/driver.mc +48/-1, src/pkg.mc +25, src/core_pkg.mc +5, src/core_build.mc +1/-1), against the step table's ~20. Zero new globals: mc limits src/mc.mc reports the same globals row before and after. Nothing the compiler emits moved -- scripts/check-inert.sh against the fork point is 33 objects identical on the plain road and 33 with --opt=1, plus the five taught examples.

Implementation notes -- step D (the taught compiler's tree) #

Written after the fact, from the consumer's measurement of 2026-09-14 (§ 7, D12).

  1. The gap reproduced here, in both shapes, before a line was written. With build/mc1 of main 21d081b: examples/avr's product (core = "<mc/core_min>", no blob) answered #include <sys>: not bundled in this compiler and mc 0.0.0-dev is not installed: run mc install, and examples/lang's (the default <mc/core>, with a blob) answered #include <sys>: not in this compiler and mc 0.0.0-dev's library tree was not found: run mc install. Both exit 1, run standalone with an empty $HOME and no --libs-dir. The consumer's own message, unknown bundled include: sys, is the third: a product that has <mc/core_build> and therefore a resolution road, with no tree at the end of it.
  1. make check-lang passes today for a different reason than adjacency, and that was checked rather than assumed. examples/lang/build/mc-lang is nowhere near the repository's build/lib/; what saves it is that grep '#include <' examples/lang/*.lx lib/*.lx *.mc finds no library name at all -- the only bundled include in the directory is <mc/core> in a comment. It never asks step 3 a question.
  1. The staging is drv_stage_libroot(drv_path(cbin)) in drv_teach, one call for both roads (the --compiler-only half returns after it). The source is the root the RUNNING mc resolved for itself, read off the map dp_mc_load already parses and caches -- src/deps.mc is included before src/driver.mc in <mc/core_build>, so the driver calls dp_mc_load() and dp_state() and reads DP_MCDIR/DP_MCN/DP_MCPATH through that file's own #defines. The alternative, bundle_read of each lib/ row, is not open to the driver: it is the same part split step C's note 1 records -- src/driver.mc is <mc/core_build> and bundle_read is <mc/core_bundle>, so it would need a fourth function pointer. And after step B it would answer almost nothing: the blob keeps src/* plus prelude and user_default, so a binary with no root has no library to stage.

3b. The whole thing is ONE function, and the frozen seed is the reason. The first draft was five -- three readers in src/deps.mc and a drv_stage_file helper -- and it turned make check red in a place nothing else in this branch touches: scripts/check-asm.sh compiles lib/mc_i128.mc and lib/mc_u128.mc with build/mc0, and those are #include "../src/core.mc" plus a module, i.e. the whole compiler in one translation unit. mc limits puts them at 2044 of the seed's MAXFUNCS 2048 on main; five more is 2049 and build/mc0 answers mc: too many functions, exit 1, 176/178 files identical. stage0/ is frozen, so the budget is what had to give: one function is 2045, three of headroom. Worth recording for whoever writes the next src/ milestone -- that ceiling, and not check-limits' own rows (which measure src/mc_seed.mc), is the tight one.

  1. The rule is scripts/libroot.sh's, restated in one loop: bundle.list plus every row whose path is not under src/, and only the files that are there. Writing a different rule in the compiler than the one the release tarball is laid with is step A note 4's drift with the two sides swapped. Measured: 44 files, the same count and the same bytes sh scripts/libroot.sh writes.
  1. Idempotence is a byte compare, not a timestamp. The loop reads the destination and does not write when the lengths and the bytes match, so a second mc build rewrites nothing -- proved with a marker file and find -newer: 0 of 44. A timestamp comparison would have been wrong on a checkout, where every source is newer than every artefact.
  1. deps_libs_for_child is kept, and the reason is worth a line: with the staging in place the spawned child would find its own root 2 anyway, but the parent's --libs-dir still wins (root 1 beats root 2, D4) and it is what makes the child's resolution order identical to the parent's on the road where the two could differ -- a <libs> the parent read and the child's freshly staged copy are the same bytes, but only one of them is what the parent actually compiled against. It is also why the staging changes no artefact: on the mc build road the staged tree is never read.
  1. The gate is scripts/check-libroot.sh case (g), which is where the other roots are proved. Its fixture is the consumer's shape reduced to what a gate can run in seconds: core = "<mc/core_min>" and a module that is examples/avr/mc-avr.mc minus the AVR -- <mc/core_machines>, <mc/core_writers>, <mc/core_build>, a main() and an empty user_init(). No <mc/core_bundle>, so the product has no blob and a successful #include <sys> is road 3 through the staged tree and nothing else. Three assertions: the tree is there (44 files), the product run standalone compiles and RUNS the program (exit 42) with an empty $HOME and no --libs-dir, and a second build rewrites 0 files. <mc/core_build> is in that list for a measured reason: without it mc_build_init() never runs, lex_set_libs is never called, and the product has no road 3 to reach a tree with -- the first draft of the fixture left it out and answered unknown bundled include: sys with the tree sitting beside it.
  1. Measured teeth. Against build/mc1 of main 21d081b, scripts/check-libroot.sh reports 7/10 with three failures -- the staged tree, the standalone product and the idempotence case, which refuses to pass vacuously when nothing was staged -- and exit 1. With this branch it is 10/10.
  1. Cost: 67 added lines in src/, 34 of them neither comment nor blank, all in src/driver.mc, and one new function. Zero new globals -- check-limits reports the same globals row before and after. Nothing the compiler emits moved: the staging writes files BESIDE the product and not into it, and scripts/check-inert.sh against main 21d081b is 33 objects identical on the plain road, 33 with --opt=1, and the five taught examples.

Implementation notes -- step E (the child's roots and the symlinked binary) #

Written after the fact, from the consumer's dry run of 2026-09-14 against main d18930d. Two defects, both real, both reproduced before a line was written.

  1. deps_libs_for_child turned the mc tree into the whole package root in the child. Step B added it for the road step D had not built yet: with no --libs-dir and nothing under $HOME/.mc/libs, the parent handed the spawned taught compiler --libs-dir <dir of mc>/lib. But <libs> serves two roles -- the mc library tree under <libs>/mc/v<ver>/ AND the installed packages under <libs>/<pack>/v<ver>/ -- and deps_libs_root() is what dep_resolve reads for the second. So a [compiler] project whose ENTRY has [deps] died in the child. Reproduced on tests/pkg/app (which has both a [compiler] and three [deps]), packages installed under a scratch HOME, no --libs-dir: compiler build/mc-app.mc -> build/mc-app, compile main.mc -> build/app, then mc: geo 1.2.0 is not fetched, exit 1 -- the consumer's own teko 0.11.0 is not fetched with a different name. mc build . --libs-dir $HOME/.mc/libs worked, which is the shape that made it look like a flag was missing. Fixed at the root: the parent forwards --libs-dir only when it was given one (dp_libs_opt), never a derived one, and deps_libs_for_child is DELETED -- since step D the child has a root 2 of its own and needs no derived answer. The order was checked rather than assumed: drv_stage_libroot(drv_path(cbin)) is called in drv_teach before drv_finish and before the drv_spawn, so the tree is beside the child when the child runs. No case remains where the child would have a tree the parent did not stage: drv_stage_libroot stages from the root the running mc resolved by roots 1/2/3, and if it found none there is no library for either of them. Cost: src/deps.mc +14/-21 (the function and its comment, replaced by a note) and src/driver.mc +12/-10 -- 6 lines of code removed and none added.
  1. host_self_path() answered the symlink's path on macOS. ~/bin/mc -> .../build/mc1 is how a developer puts one compiler on PATH, and _NSGetExecutablePath answers the path as invoked, so roots 2 and 3 were computed from ~/bin/ and the tree beside the real binary was invisible: #include <sys>: not in this compiler and mc 0.0.0-dev's library tree was not found: run mc install, exit 1, measured. src/host_macos.mc passes the answer through realpath(3) (libSystem, buffer xalloced, so no global) and keeps the raw path when that fails. The other two hosts need nothing and that was read, not assumed: readlink("/proc/self/exe") returns the target the kernel resolved, and GetModuleFileNameA answers the module's own path. The doc row in ../reference/hooks.md § 6 said the opposite in prose ("the answer is not resolved any further") and is corrected with the code. One observable consequence, and it is the fix working: mc upgrade with no --to prints the RESOLVED path on its into line, so it replaces the compiler and not the link. scripts/check-pkg.sh § 33i asserted that line with $tmp spelled out, and $TMPDIR on macOS is /var/folders/..., a symlink to /private/var -- the case now asserts the suffix and prints the whole line, which is where the /private/var/... in the run's output comes from.
  1. The gates. scripts/check-pkg.sh § 39: mc pkg sync --yes with no --libs-dir and a scratch HOME writes $HOME/.mc/libs, then mc build on the [compiler] config with no flag at all builds and the program runs (exit 42, plot 110) -- which proves both halves in one command, since the entry also says #include <sys> and therefore needs the staged tree; then the same config with an explicit --libs-dir still builds and runs, so the forwarded flag still wins. scripts/check-libroot.sh case (h): a symlink in a directory with no lib/ of its own compiles #include <sys> -- a REAL name, because m52probe exists only in the trees that script lays and would say nothing about the tree beside build/mc1.

Edit this page