Spec M48 -- packages, second evolution: mclib, tools, [[permission]], and the NuGet-like registry

Owner's five statements (2026-09-06, Portuguese), read as: (1) lib/ is ONE package, mclib, released with the compiler's own version; (2) tools are packages of kind = "exe" that mc tool install compiles on the developer's machine, run in the sandbox under permissions the package requests and the developer confirms; (3) libraries are kind = "obj", tools kind = "exe", the registry detects the kind from the manifest inside the published archive, and the site crosses dependencies, downloads and the rest, NuGet-style; (4) [[tool]] states tool dependencies, [[permission]] states permissions for any kind, and installing either shows the permissions and the sub-dependencies, which the server records. Written against minicompiler/mc main at da506ce (v0.15.8) and minicompiler/mc-registry at 362ed5f (branch registry-alias; not checked out or pulled here -- read-only session).

Amendment (owner, 2026-09-06): [tools] is a key table, not [[tool]]. The owner wrote [[tool]] on the assumption that dependencies were arrays; asked for "the best form", tool dependencies take EXACTLY the shape [deps] already has -- [tools] with name = "minimum" rows -- so a project reads one way for both kinds of dependency and the lock/index need no second grammar. [[permission]] stays an array of tables (a row has several keys). [package].bin stays. Every [[tool]] below reads as [tools]; the index/lock key tools keeps the "<name> <minimum>" string form, which is [deps]'s.

Amendment (owner, 2026-09-06): no --unconfined. The behaviour is the same on every host. mc tool install NAME --yes prints the same plan and the same permission table and accepts them the same way on macOS, Windows and Linux; mc tool run boxes the tool where the host has a sandbox and runs the binary directly where it has none. The only host-specific thing is ONE informational line in the plan on a host without a box (note: this host has no sandbox; the permissions above are recorded, not enforced), and the install manifest records sandbox = true|false as a fact mc tool list shows. No flag, no exit 2, no second road; when a host gains a sandbox (M43 Layer 2 or later) the same install runs boxed with no re-install. § 3.4's "macOS and Windows" paragraph, D7's last clause and risk 11 read accordingly.

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

1. The manifest #

1.1 The kind rule #

the package's mc.tomlkindwho it is
[package] and no [project]libevery manifest published or fixtured so far: mc, teko, geo, mathx, teach, float
[package] + [project] kind = "obj"liba library that also builds an object of its own (rare; allowed)
[package] + [project] kind = "exe"toola program mc tool install builds and runs
[package] + [project] with no kind linerefused by the validator (a package's [project] must say kind = "obj" or "exe")the driver defaults kind to exe (docs/reference/toml.md § [project]), and a library that carried a [project] for its tests would be silently classified as a tool. No published package has this shape, so nothing breaks

kind is a property of the name: the validator refuses a version whose kind differs from the first published version's (refused: this package is a lib; a tool cannot become a library, an attempts row). The compiler reads [project].kind exactly where it does today (drv_run, src/driver.mc:775) and nowhere new: for the consumer, the kind arrives in the index row and in the lock (§ 4.3).

1.2 The binary name: [package].bin, not a [tool] table #

The consolidated reading pairs a [tool] table (the binary's name) with [[tool]] rows (dependencies). TOML forbids that: a table and an array of tables may not share a name (TOML 1.0 § Array of Tables), and while src/toml.mc's flat table would take both (tool.name beside tool.0.name), the registry's reader (mf_header treats every header as a table) and every editor's TOML mode would call the file invalid. The owner's own words name only [[tool]] and [[permission]]; the [tool] table was the reading's addition. Decision:

[package]
name = "mc_lsp"
bin  = "mc-lsp"            # optional: the name in ~/.mc/bin; default = basename of [project].out

bin is [a-z][a-z0-9_-]*, at most 32 bytes (a hyphen is allowed here and nowhere else, because it is a file name and not an identifier). It is read by mc tool install and by the registry; mc build ignores it.

1.3 [[tool]] -- dependencies on tools #

[[tool]]
name    = "mc_lsp"
version = "0.1.0"          # a minimum, like [deps]

Allowed in a project and in a package. Each name must be a registered package of kind tool (§ 5.2). In a project, mc pkg sync resolves them in the same MVS graph as [deps] (a tool's own [deps] are libraries) and writes them into the lock with kind = "tool"; they register no include root (a tool is not compiled into the consumer) and are what mc tool install with no argument installs (§ 3.2). In a package they are transparency for now: the registry records and shows them, the validator checks they are registered, and nothing runs them during validation (a package whose test needs a tool is the priced follow-up in § 7).

Why an array of tables and not a [tools] key table like [deps]: the owner asked for [[tool]], and a row has room for a second key later (optional = true, or a bin override) without a second spelling.

1.4 [[permission]] -- grammar, defaults, workspace, paths #

[[permission]]
kind   = "fs.read"                 # fs.read | fs.write | net | exec | env
path   = "workspace"               # fs.*: workspace | workspace/<rel> | home/<rel> | tmp
name   = "mc"                      # exec: a program's basename;  env: a variable name
reason = "reads the sources it is asked about"   # optional, <= 120 bytes; the site shows it
kindrequired keymeaning, in the words the developer reads at install
fs.readpathmay read files under PATH
fs.writepathmay create, change and delete files under PATH
net--may open network connections (any host, any port -- § 7.1 says why nothing finer)
execnamemay run the program NAME found on your PATH
envnamemay read the environment variable NAME

Paths. No absolute path is ever accepted: the validator cannot verify one, it leaks a host's layout into a manifest, and the box cannot promise what it maps to. The four forms are:

Defaults. No [[permission]] means stdio only: a tool reads its stdin, writes its stdout and stderr, sees its own install tree read-only, and nothing else. A library with no rows declares that code using it needs nothing beyond what the program itself has.

Canonical one-line form (the lock, the index row, the install table): fs.read workspace, fs.write workspace/build, fs.read home/.cache/mc-lsp, net, exec mc, env HOME. Duplicates collapse; the set is sorted bytewise wherever it is written (rule 2 of docs/determinism.md: a total order over unique keys). reason is not part of the canonical form -- it is shown on the site and by mc pkg list --long, never hashed into a decision.

1.5 [package].test -- what the validator RUNS #

check (§ 22.1 of the registry spec) names units the validator compiles. Nothing today names a unit it runs except "the package's own [project]", and once [project] kind = "exe" means tool, that second box would run an LSP with no arguments. So:

[package]
check = ["float_rt.mc"]            # compiled, one box each (unchanged)
test  = ["tests/smoke.mc"]         # compiled AND run, one box each, under the derived profile (§ 4.1); exit 0 = pass

test entries obey check's rules (package-relative, dep_rel_ok, listed in files). The compiler ignores the key. It is what makes "a package doing more than it declares is refused by name" a measurement and not a sentence: the test program runs inside the profile derived from the package's own [[permission]] rows, and a refused: line is a refused publication. The validator's "second box over the package's [project]" is retired in favour of test for both kinds (for a tool the [project] is built in a box, § 5.3).

1.6 [package].licence #

An SPDX identifier string ("MIT", "Apache-2.0"), at most 64 bytes of [A-Za-z0-9.+-], or absent. The registry stores and shows it; the compiler ignores it. Not validated against the SPDX list (no list to ship); shown as written, escaped.

1.7 What each side reads #

keycompilerregistry
[package] name files lib modulereads (unchanged)reads
[package] check test licence descriptionignoresreads
[package] binmc tool install readsreads
[project] kindmc build reads (unchanged); mc pkg/mc tool read it to classify a fetched treereads (the kind rule)
[deps]reads (unchanged)reads
[[tool]] name versionmc pkg sync, mc tool install readreads, checks registered
[[permission]] kind path namemc pkg sync|add, mc tool install|run readreads; derives the box profile
[[permission]] reasonmc pkg list --long printsshows

1.8 Backward compatibility, manifest by manifest #

2. mclib: lib/ as one package, versioned with the compiler #

2.1 What the package is #

lib/mc.toml, checked in:

[package]
name        = "mclib"
description = "The libraries the mc compiler ships: float, i128, f16, prelude, io, sys, the system layers and the teaching demos, at this compiler's version"
licence     = "MIT"                 # whatever LICENSE says; the owner's call
# no [project]: a library
check = ["prelude.mc", "io.mc", "float_rt.mc", "sys_linux_x86_64.mc"]   # units that compile alone on the worker (linux/x86_64); the implementer measures the set with the S5 § 5.1 rehearsal
files = [ "backend_arm64.mc", "f16.mc", "float.mc", ... ]               # the `lib/` rows of tools/bundle.list, paths relative to lib/, byte order

files is generated by the same one-liner the root manifest uses (grep ' lib/' tools/bundle.list | cut -f2 | sed 's|^lib/||' | LC_ALL=C sort -u; 39 entries, 312 KiB today) and scripts/check-pkg.sh § 30a-style asserts no drift. lib/linux/<arch>/sys_arch.mc are not bundle rows and are not in the package. No lib key: there is no single entry point, so a bare <mclib> is refused and a consumer writes <mclib/float_rt.mc>, <mclib/prelude.mc>.

What mclib is and is not for. It is <mclib/...>: a project pinning [deps] mclib = "0.16.0" on any compiler gets those trees under that spelling, hashed and locked. It does not override the bare bundled names: resolution step 1 (lex_include_name, packages.md § 2) matches a lock row by the FIRST path component, so <float> still comes from the blob unless the project pins a package named float (A5). Publishing float, i128, ... as packages of their own is M47-S5 § 4 as priced there and is not this spec: one package, the owner's word. The value is (a) the registry's dogfood at every release, (b) the base every tool depends on by version, (c) a newer lib/ on an older compiler where the hook surface allows it, (d) the road mc install (§ 2.6) and the LSP (§ 3.6) both take.

2.2 The two roads, priced #

A. release asset (recommended)B. subdirectory of the tag archive
the row's urlhttps://github.com/minicompiler/mc/releases/download/v<ver>/mc-libs-<ver>.tar.gz, strip = 1 (one top directory mc-libs-<ver>/, release-assets.sh's convention)the tag archive, plus a new row key subdir = "lib"
what a shipped compiler does with itpkg_fetch_one -> fetch_get (one HTTPS redirect, measured) -> fetch_check_members -> extract -> dep_hash_tree -> manifest. Zero compiler lines: url, strip, sha256 are all it readsa 0.15.x compiler ignores subdir, extracts the whole repository with strip = 1, reads the ROOT mc.toml and dies with the archive's mc.toml names another package (pkg_check_archive) or a hash mismatch. Every consumer must upgrade first
compiler cost0~70 lines in pkg.mc/fetch.mc (subdir in VR_*, a member subset to fetch_extract -- whose post-extraction "every member is openable" check is skipped for subsets today -- and the strip arithmetic)
mc repository costlib/mc.toml (~60 lines); scripts/release-assets.sh --libs VERSION [OUTDIR] (~45 lines: stage lib/ + LICENSE under mc-libs-<ver>/, the existing sorted-members/mtime-0/ustar/gzip -n rules, the .sha256); release.yml build job: one step after Package (scripts/release-assets.sh --libs "$VERSION" dist; the upload glob dist/mc-*.tar.gz and publish's gh release create dist/mc-*.tar.gz already match the name -- zero lines there); check-pkg.sh +30 (files drift, mc pkg hash lib == scripts/pkg-hash.sh lib, the tarball reproducible)lib/mc.toml and the same gates; no asset
registry costrepos.subpath + repos.asset (§ 5.1), the validator hashing <checkout>/<subpath> and writing the asset URL, the poll fanning out to every row of a triple (~110 lines)repos.subpath and the validator hashing the subtree (~70 lines); the index generator emitting subdir
when the archive existsafter publish (the job publish-to-registry is needs: publish; a scheduled poll that arrives earlier gets a 404 from the download half and treats it as "not ready", no attempts row -- the § 24.1 rule, extended to assets)at the tag
what changes in a row's semanticsnothing: a row is url strip sha256 deps, as M44 § 5 wrote ita second way to name a tree

Decision D1: road A. It is the road with zero compiler lines, it works for every compiler already released, and the archive is produced by the script that already produces every other release artefact under the determinism rules. The asset's .sha256 is for a person; the row's sha256 is the tree hash over lib/mc.toml + files, which the validator computes on the tag's lib/ subtree and a consumer recomputes after extraction -- so the asset's bytes are verified by content, and a regenerated or tampered asset is checksum mismatch for mclib 0.16.0 at every consumer.

2.3 What the index row looks like #

[[versions]]
version     = "0.16.0"
url         = "https://github.com/minicompiler/mc/releases/download/v0.16.0/mc-libs-0.16.0.tar.gz"
strip       = 1
sha256      = "<tree hash of lib/ at v0.16.0>"
deps        = []
kind        = "lib"
permissions = []
tools       = []
licence     = "MIT"

2.4 The admin registration #

mcreg-admin register git_url=https://github.com/minicompiler/mc account=<owner login> subpath=lib asset=mc-libs-{version}.tar.gz reason=... -- register gains two optional parameters (§ 5.5); the row's name is read from <default branch>/<subpath>/mc.toml (rg_register fetches the raw file: the path gains the subpath). The reserved prefix is lifted by allow_reserved = 1 as for mc. The forge triple is now (host, owner, repo, subpath) (§ 5.1).

2.5 What mc pkg sync needs, and mc pkg check's download half #

Nothing. sync plans mclib 0.16.0 with the asset URL, --yes downloads it through the same downloader as a sysroot, checks members, extracts with strip = 1, hashes, writes <libs>/mclib/v0.16.0.toml last. mc pkg check index/mclib.toml --yes (the registry's recheck download half; mclib is not in the compiler's exact reserved set, so § 22.2's skip does not apply) downloads the asset and compares the tree hash, the name and the deps -- unchanged code. The one observable difference from a tag archive is the redirect target; § 0 measured that the flags admit it.

2.6 What this does to M44 steps 4-5 (amendment, to ratify) #

B4/B5 named the slim binary's archive mc-libs-<ver>.tar.gz and had release-assets.sh --libs produce it by running mc install --from-bundle into a staging directory. Reassign the name to mclib's asset and have mc install fetch the mc package's own index row (https://pkg.minicompiler.dev/index/mc.toml -> the tag archive, strip = 1, the 97-file files list hashed exactly as any package) into <libs>/mc/v<mc_version()>/, copying tools/bundle.list to the root of the tree afterwards (M47-S5 § 3.2's note). One fetcher, one hash, one row that the registry already publishes; the slim binary's second archive and its hand-listed member set disappear. What does not come for free and stays step 4's: the installed src/bundle_data.mc is the checked-in (mode 0) form, so a taught compiler built from an on-disk <mc/core> pays the M21.5 arena cost -- step 4 measures whether that matters and may rewrite the file in mode 1 after the hash check (the hash is over files; a rewritten file must not be in the list, or the tree is written first and hashed second as today).

3. Tools #

3.1 The commands (src/tool.mc, in <mc/core_pkg>, one subcommand("tool", ...)) #

mc tool install NAME[@VERSION] [--yes] [--unconfined] [--registry URL|DIR] [--libs-dir DIR] [--bin-dir DIR]
mc tool install [DIR] [--config FILE] [--yes] [--unconfined] ...     every [[tool]] of the project's lock
mc tool list    [--bin-dir DIR]
mc tool remove  NAME [--bin-dir DIR]
mc tool upgrade [NAME] [--yes] [--unconfined] ...                    the newest non-yanked, non-pre-release row of the same major, then install
mc tool run     NAME [--] [ARGS]                                     what the launcher calls

install is, in order: (1) load the index rows and run MVS with the tool as the root and its [deps] as requirements (pkg_resolve, unchanged: a tool's dependencies are libraries); (2) print the plan -- the archives, AND the permission table of § 4.2 for the tool and every library under it -- and stop without --yes; (3) fetch into <libs> exactly as sync does; (4) stage a buildable copy: ~/.mc/tools/<name>/v<ver>/ receives mc.toml + files (the pkg_copy_tree road), deps/<dep>/ for every library (the vendor road), and an mc.lock written by pkg_write_lock -- the shape the validator itself builds (§ 18 of the registry spec) and the shape mc build rehashes; (5) mc build that directory by spawning <self> build DIR --libs-dir ... (drv_spawn, the drv_teach precedent for "the binary that is running builds the next thing"); (6) write ~/.mc/tools/<name>/v<ver>.toml last -- [tool] name version bin sha256 kind unconfined + permissions = [...] in canonical form -- and the launcher ~/.mc/bin/<bin>; (7) print the PATH line once (add ~/.mc/bin to your PATH).

Why the build is not boxed: mc sandbox run keeps every write in an overlay that dies with the box (M43 § Out of scope: no --keep), and a tool's build must produce a file that survives. The build runs with the trust mc build of any project has today -- its libraries are compiled, its [compiler].modules run inside the taught compiler (M44 Risk 1). The validator has already compiled the same tree in a box; what runs on the developer's machine is the tool itself, boxed (§ 3.3). Boxing the build is priced in § 7.

3.2 The ~/.mc layout #

~/.mc/libs/<pack>/v<ver>/          the fetched trees (unchanged, D10')
~/.mc/tools/<name>/v<ver>/         the staged, built copy: mc.toml, files, deps/, mc.lock, build/<bin>
~/.mc/tools/<name>/v<ver>.toml     the install manifest (written last; the claim)
~/.mc/bin/<bin>                    the launcher (macOS/Linux: a two-line sh; Windows: <bin>.cmd)

--bin-dir and --libs-dir override the two roots (the --sysroot-dir precedent; CI depends on no HOME). ~/.mc/bin/<bin> names ONE version -- the newest installed; mc tool run inside a project whose lock pins another version runs that one (the lock wins, as it does for includes). remove unlinks the launcher and the two manifests' listed files (mc has no rmdir: the directories stay, empty -- is not installed afterwards, the outcome that was wanted).

3.3 The launcher: a one-line script, and the logic in mc tool run #

#!/bin/sh
exec mc tool run mc-lsp -- "$@"

and on Windows @mc tool run mc-lsp -- %*. Not a tiny mc binary that execs: mc has posix_spawnp + waitpid and no exec, so a binary launcher would be a spawn-and-wait too; and --exe is unavailable on Windows without a linker, so a compiled launcher would need the object+lld-link road for one line. The script carries no derived flags on purpose: what runs is decided by mc tool run from the install manifest, so a change in how permissions map to the box (§ 4.1) never needs launchers regenerated, and a person reading ~/.mc/bin/mc-lsp sees one line.

mc tool run NAME (Linux, host_sandbox_supported()): reads ~/.mc/tools/<name>/v<ver>.toml, derives the mc sandbox exec line (§ 4.1), spawns it with stdio inherited (no file actions: an LSP's stdin/stdout pass straight through, as the box already passes P's fds 1 and 2), waits, exits with the child's code (128 + signal for a killed child, drv_spawn's rule). Two processes more than a bare binary (mc tool run, then mc sandbox exec's P) -- the box's measured cost is 1.7-3.7 ms (sandbox.md § Hosts), which an editor never sees.

mc tool run NAME (macOS, Windows): runs the binary directly, only if the install manifest says unconfined = true (§ 3.4); otherwise mc: mc-lsp was installed for a sandbox this host does not have: re-install with --unconfined, exit 2.

3.4 What mc sandbox lacks, and the macOS/Windows rule #

mc sandbox exec gains, in sb_parse/sb_build_tree/sb_landlock_apply/the profile:

flagbox mechanismlines
--rw DIRbind DIR read-write at its own absolute path inside the box (mkdir the components on the box tmpfs, then MS_BIND without the read-only remount); Landlock: the full fs mask on that pathsandbox_box.mc +45, seccomp.mc +8
--ro DIRexists; gains the same-path placement when --at-path is given (a tool receives host paths from its caller -- an LSP is handed file:///Users/x/proj/main.mc -- so /roN numbering, right for the test corpus, is wrong for tools)+15
--tmpa writable /tmp on the box tmpfs; Landlock full+12
--allow=netkeep the host's network namespace (unshare without CLONE_NEWNET); do not put the net field into the Landlock ruleset; add the net delta to the profile: socket connect bind listen accept4 sendto recvfrom sendmsg recvmsg setsockopt getsockopt getsockname getpeername shutdown -- measured by scripts/sandbox-trace.sh over a probe that does a TCP round trip, written to tools/sandbox/<arch>-<libc>-net.list, never typedsandbox.mc +10, sandbox_box.mc +6, seccomp.mc +6, sandbox_profiles.mc generated, sandbox-trace.sh +40
--bin PROGresolve PROG on the host's PATH (a host_which() walk over host_environ(), ~25 lines, the host_home shape), bind it read-only+exec at /bin/<basename>, PATH=/bin in the box; Landlock LL_FILE_EXEC; the spawn delta (the compile profile's clone/clone3/wait4/pipe2/dup3... minus what the program profile has, measured) and the process counter set to 16 like a compile step, the execve counter to 1 + the number of --bin flagssandbox_box.mc +30, sandbox.mc +20, seccomp.mc +10
--env NAMEcopy NAME=<value> from the host environment into the box's environment (three entries today, sb_env())sandbox.mc +15

Every new flag is a primitive; none knows what a permission is. tests/sandbox/ gains one case per flag (a program that writes under --rw and is refused one directory up; a connect that succeeds under --allow=net and is refused: syscall 203 (connect) without; an execve of /bin/true that succeeds under --bin true and is refused: process limit (0) without; getenv through --env), each with an .expect. scripts/sandbox-trace.sh --check in CI keeps the two new lists honest in both directions.

macOS and Windows. There is no box (M43 § 7; Layer 2 is post-1.0.0 and is about a web playground, not a Mac box). Rule: mc tool install on such a host prints the permission table and stops with

mc: this host has no sandbox: mc-lsp would run with the permissions above and nothing to hold it to them
  run:   mc tool install mc_lsp --yes --unconfined

exit 2. With --unconfined it installs, records unconfined = true in the install manifest, and mc tool run runs the binary directly and silently thereafter (accepted once, at install -- the same rule as the lock). The word is --unconfined and not --yes, so that a CI script written for Linux does not silently accept "no box" when run on a Mac.

3.5 The workspace literal #

workspace is the current working directory of the invocation -- what mc build . means by ., and what an editor sets when it launches a language server (VS Code: the workspace folder). mc tool run resolves it once (sb_getcwd, which exists) and refuses two values without an explicit --workspace DIR: $HOME and / -- a tool started from a shell in the home directory would otherwise be granted every file the user owns under the word "workspace". § 7.2 says what this does not close.

3.6 The LSP as the first consumer #

[package]
name    = "mc_lsp"
bin     = "mc-lsp"
files   = ["lsp.mc", "json.mc", "rpc.mc", "tests/smoke.mc", ...]
check   = ["lsp.mc"]
test    = ["tests/smoke.mc"]
licence = "MIT"

[project]
entry = "lsp.mc"
out   = "build/mc-lsp"
kind  = "exe"

[deps]
mclib = "0.16.0"

[[permission]]
kind = "fs.read"
path = "workspace"
reason = "reads the sources the editor opens and the project's mc.toml"

[[permission]]
kind = "exec"
name = "mc"
reason = "builds the project's taught compiler for diagnostics through the taught surface"

exec mc is the LSP's honest need (§ 7.3): the project-aware half of M28 (examples/lang's taught words) is a taught compiler, which only mc build produces.

4. Permissions end to end #

4.1 The validator's derivation of the box profile (kind by kind) #

permissionM43 mechanism the validator (and mc tool run) uses
(none)mc sandbox exec as today minus the writable /src: the tool's install tree bound read-only (--ro <tree> --at-path), cwd there, empty netns, program profile, HOME=/src PATH=/, process counter 0
fs.read workspace / workspace/<rel> / home/<rel>--ro <resolved dir> --at-path: bind + MS_RDONLY remount, Landlock LL_READ_ONLY on the path
fs.write ...--rw <resolved dir>: bind without the remount, Landlock full fs mask; for home/<rel> the launcher mkdirs the path first (mc has mkdir), so a cache directory can be created by the tool inside a directory that exists
tmp--tmp
net--allow=net: no CLONE_NEWNET, no Landlock net field, the measured net delta in the seccomp allowlist
exec NAME--bin NAME: the program bound ro+exec at /bin/NAME, PATH=/bin, the spawn delta, process counter 16, execve counter 1 + N
env NAME--env NAME

The same function, tool_box_argv(manifest), in src/tool.mc, is what mc tool run executes and what the registry's jb_sandbox writes for a test box (§ 5.3) -- the registry does not re-implement the mapping: it spawns mc tool box-args <mc.toml> (a hidden verb, ~10 lines, prints the derived flags one per line) so that there is ONE definition of "what fs.read workspace means", in the compiler, versioned with it. In the validator's box workspace is the checkout, home/<rel> is a directory under the box tmpfs, and exec mc binds the worker's own mc.

What is refused by name: a test program that opens a file outside its declared roots gets sandbox: refused: open /etc/passwd; one that connects without net gets refused: syscall N (socket); one that forks without exec gets refused: process limit (0). Each is exit 125, the unit is not published, and the line is in the report the author reads (GET /jobs/<id>).

4.2 The install table (the compiler's union-and-confirm) #

mc pkg sync|add|update and mc tool install, after MVS and before any download, print one block per package in the build list whose permission set is not already accepted by the current lock (§ 4.3), then the tools required:

fetch  mc_lsp 0.1.0            tool  bin mc-lsp
url    https://github.com/minicompiler/mc-lsp/archive/refs/tags/v0.1.0.tar.gz
sha256 8c1e...
into   /Users/me/.mc/libs/mc_lsp/v0.1.0/

permissions
  mc_lsp 0.1.0     fs.read workspace     may read files under the directory it is run from
                   exec mc               may run the program `mc` found on your PATH
  mclib 0.16.0     (none: stdio only)
tools required by this project
  mc_lsp >= 0.1.0
nothing was downloaded: re-run with --yes to fetch and to accept the permissions above

The permission set comes from the index row (permissions = [...]), so the developer sees it before a byte is fetched; after the fetch the tree's mc.toml is what the lock is written from (pkg_write_lock's rule), and the two cannot disagree without the tree hash disagreeing too. The wording per kind is fixed text in tool.mc (the fourth column above), one sentence per kind, so the table reads the same on every machine.

--yes accepts what was printed. There is no second flag: --yes already means "I read the plan", the plan now includes the permissions, and the lock diff in the project's git history is the review record for a CI run. sync with nothing to download but a permission set not covered by the lock also stops without --yes (today's "nothing to download needs no --yes" deviation keeps its case: covered sets and no fetch).

4.3 The lock record (exact TOML) #

# written by `mc pkg sync` -- do not edit (docs/reference/packages.md)
[[package]]
name        = "mc_lsp"
version     = "0.1.0"
kind        = "tool"
bin         = "mc-lsp"
sha256      = "8c1e..."
deps        = ["mclib"]
permissions = ["exec mc", "fs.read workspace"]

[[package]]
name        = "mclib"
version     = "0.16.0"
sha256      = "..."
deps        = []
permissions = []

kind is written only for a tool (absent = lib, so every existing lock and every fixture lock reads as before and the mc.lock.expect goldens move once, for the permissions line). permissions is the set accepted, sorted. Rule for "asks again": before writing the new lock, for each row compare its set with the OLD lock's row of the same name (any version): a set that is not a subset of the old one is unaccepted and needs --yes; a row absent from the old lock is unaccepted; an old row without a permissions key is an empty accepted set. A dependency whose new version adds net is therefore printed and stops a sync without --yes, and a version that drops a permission is silent. mc pkg list gains a column (stdio, or the canonical set), mc pkg list --long prints the reasons out of the trees.

dep_read_lock reads the two keys into PK_* (+2 fields); deps_apply uses neither -- a build asks nothing (D24: a project with a lock builds byte for byte; permissions are an install-time fact). The registered roots skip kind = "tool" rows.

4.4 What the registry cannot verify for a library, said plainly #

A library is compiled into the consumer's program; there is no process to box. Its [[permission]] rows are the author's statement, checked only where a test unit exercises them (§ 1.5) and shown at install. The install table says so: (declared by the author; a library runs inside your program) on every lib row that has permissions. What the validator DOES hold a library to is unchanged and mechanical: the closure rule on every file the check units read, the hash, and the box around check and test. A library with a compiler module is the one case where library code RUNS on the developer's machine at build time (M44 Risk 1); its row is marked runs at build time in the table, and boxing drv_teach's spawn under the union of module permissions is § 7.6.

5. The registry side #

5.1 Migration db/006-kinds.sql (~70 lines) #

ALTER TABLE repos ADD COLUMN subpath text NOT NULL DEFAULT '';           -- '' = the root; 'lib' for mclib
ALTER TABLE repos ADD COLUMN asset   text NOT NULL DEFAULT '';           -- '' = the tag archive; a name with {version}
ALTER TABLE repos ADD COLUMN kind    text NOT NULL DEFAULT ''             -- '' until the first version is published
    CHECK (kind IN ('', 'lib', 'tool'));
DROP INDEX repos_forge_key;
CREATE UNIQUE INDEX repos_forge_key ON repos (forge_host, forge_owner, forge_repo, subpath) WHERE forge_host <> '';

ALTER TABLE versions ADD COLUMN kind        text   NOT NULL DEFAULT 'lib' CHECK (kind IN ('lib', 'tool'));
ALTER TABLE versions ADD COLUMN bin         text   NOT NULL DEFAULT '';
ALTER TABLE versions ADD COLUMN licence     text   NOT NULL DEFAULT '';
ALTER TABLE versions ADD COLUMN permissions text[] NOT NULL DEFAULT '{}';  -- canonical one-liners, sorted
ALTER TABLE versions ADD COLUMN tools       text[] NOT NULL DEFAULT '{}';  -- "<name> <minimum>", the [deps] shape
-- versions_immutable(): the five new columns join the immutable list (replace the function, as 004 and 005 did)

CREATE TABLE fetches (            -- index fetches, per package per UTC day: what this server can measure
    repo_id bigint NOT NULL REFERENCES repos(id),
    day     date   NOT NULL,
    n       bigint NOT NULL DEFAULT 0,
    PRIMARY KEY (repo_id, day)
);
CREATE TABLE asset_counts (       -- GitHub's own download_count for asset-published versions, refreshed by recheck
    version_id bigint PRIMARY KEY REFERENCES versions(id),
    n          bigint NOT NULL,
    read_at    timestamptz NOT NULL
);

Two counter tables and not columns on versions, so the immutability trigger is untouched and a counter is a fact about traffic, not about the row. Every published row gets kind = 'lib', permissions = '{}' by default -- true for all of them.

5.2 The validator's rules (web/job.mc, web/manifest.mc) #

mf_parse gains: mf_bin, mf_licence, mf_test (an array, MF_MAXCHECK), mf_tools (rows: name version, MF_MAXDEPS), mf_perms (rows in canonical form, at most 32; kind outside the five words, a path failing mf_rel_ok after the workspace//home/ prefix is cut, a path on net/exec/env, a missing name on exec/env -> mf_fail, refused with the offending row named). Reading an array of tables is new to this reader: [[permission]] and [[tool]] headers start a row; keys land in the current row (~90 lines).

jb_do_tag, after the manifest checks and before the hash:

  1. kind: mf_project == 0 || kind == "obj" -> lib; kind == "exe" -> tool; mf_project && kind == "" -> refused: a package's [project] must say kind, attempts row. A tool must have a [project].out; bin defaults to its basename and must match [a-z][a-z0-9_-]*. If repos.kind is set and differs -> refused: <name> is a <kind>; a version cannot change that, attempts row. lib with kind = "obj" is allowed and pointless; the report says note: [project] kind = "obj" builds nothing a consumer uses.
  2. dependency-registered rule: every [deps] name and every [[tool]] name must be a repos row in state active (or pending for the package's own name? no: a package cannot depend on itself), with at least one non-delisted version >= the minimum; a [[tool]] name must be kind = 'tool', a [deps] name kind = 'lib'. Refused with the name, attempts row. (mc's own manifest has no deps; mclib has none; the first tool depends on mclib, which is why mclib publishes first, § 6.)
  3. subpath and asset (repos.subpath <> ''): the tree is <checkout>/<subpath>; the vendored copy under check/deps/<name>/ is that subtree; mc pkg hash runs on it. With repos.asset <> '' the row's url is https://github.com/<o>/<r>/releases/download/<tag>/<asset with {version} replaced>, and before publishing the worker runs the download half once: mc pkg check of a one-row index it writes, --yes, in the work directory (this is jb_do_recheck's existing road, moved into a function): a 404 from the downloader is "the asset is not there yet" -- no attempts row, next poll -- and any other disagreement is a refusal.
  4. the boxes: the check units as today (a compile, kind = "obj" project); for a tool, one more compile box that BUILDS the package's own [project] (mc build of the vendored tree, in the box, --out capped; the output is discarded with the overlay -- the point is that it builds); then each test unit compiled and run in a box whose flags are mc tool box-args <tree>/mc.toml (§ 4.1), with workspace = the vendored tree. The former "second box over the package's [project]" is retired (nothing published depends on it).
  5. sql_version_insert gains the five columns (16 parameters; PG_MAXPARAMS is 16, measured in § 18 -- raise it to 24 in the same change). repos.kind is set when '' (UPDATE ... WHERE kind = '').

jb_do_poll's lookup of "the repository" becomes "every row of this triple", one job per row (the poll endpoint and the admin poll queue one job per row; sql_job_pending is per row already).

5.3 The index (pkg_index_toml) and the counters (web/registry.mc) #

Each [[versions]] row gains kind (always), bin (tools), licence (when set), permissions = [...], tools = [...] -- written through toml_str, after deps, before yanked/advisory, so web/tests/registry/*.toml goldens change by four lines per row and the S2 comparison is re-recorded once. reg_index (and the alias, one handler) does INSERT INTO fetches ... ON CONFLICT (repo_id, day) DO UPDATE SET n = n + 1 after a 200 -- one write per origin hit, bounded in size by (packages x days), undercounted by the 300 s edge cache and Cloudflare, and named for exactly that on the page: "index fetches (origin, per day)". The weekly recheck reads the Release JSON it already fetches (jb_has_release, the token road; web/json.mc keeps a tree since S4b) for assets[].download_count of the row's asset and upserts asset_counts: "asset downloads (GitHub's counter)". Two numbers, each named for what it measures; neither is "downloads".

5.4 The NuGet-like pages #

/packages/<name> (pages_one), in this order, each a <h2>:

  1. header: name, kind badge (library / tool), description, licence, the source link, the [deps] line or -- for a tool -- mc tool install <name> --yes;
  2. Permissions of the latest version: the canonical line, the fixed sentence, the author's reason; (none: stdio only); for a library the § 4.4 caveat in one line;
  3. Dependencies (the latest version's deps and tools, each a link) and Dependents: SELECT DISTINCT r.name FROM versions v JOIN repos r ON r.id = v.repo_id WHERE r.state = 'active' AND v.delisted_at IS NULL AND EXISTS (SELECT 1 FROM unnest(v.deps || v.tools) d WHERE starts_with(d, $1 || ' ')) -- starts_with, never LIKE (a name may contain _, which is a LIKE wildcard; the file's own rule); at most 200, ordered by name;
  4. Versions: the table gains kind, permissions (as a compact list), pre-release, yanked, advisory, source missing, and the report link of the job that published it (jobs by repo_id, state = 'done', report LIKE '%published <version>%' is not a query; a versions.job_id bigint column written at insert is -- add it to 006, immutable);
  5. Activity: index fetches, last 30 days, and the asset count where present;
  6. the report form link and the index URL, as today.

/packages gains a kind column and a kind=tool filter (?kind=), and the search stays a prefix.

5.5 The public MCP (S7) JSON, and the admin tools that change #

S7's package tool answers one object; the columns above are its fields, so S7 should land after 006 or add them itself:

{"name":"mc_lsp","kind":"tool","bin":"mc-lsp","description":"...","repo":"https://github.com/minicompiler/mc-lsp","licence":"MIT","latest":"0.1.0",
 "versions":[{"version":"0.1.0","kind":"tool","tree_hash":"8c1e...","url":"...","deps":["mclib 0.16.0"],"tools":[],"permissions":["exec mc","fs.read workspace"],"prerelease":false,"yanked":false,"advisory":"","published":"2026-09-20","job":41}],
 "dependents":["mc_fmt"],"fetches":{"index_30d":120,"asset":null}}

resolve (the MVS tool) returns the same union table mc pkg sync prints, as {"packages":[{"name","version","kind","permissions"}],"tools":[...]}. Admin (web/admin.mc): register gains subpath, asset; stats gains fetch totals; a new set_kind does not exist (kind is the manifest's); everything else unchanged. docs/admin.md gains the two parameters.

6. Sequence, gates, costs #

6.1 Where it lands #

Current state: compiler main at v0.15.8 with M44 steps 1-3 and M43 done, steps 4-5 (slim, install, upgrade) not started; registry at S6 + the index alias (§ 26), S7 (public MCP + account tokens) not started; publish-to-registry gated by MC_REGISTRY_PUBLISH until the 1.0.0 flip.

Rule for the order: a row the registry serves must never be one a released compiler cannot read, and a key a compiler writes must never be one the registry cannot store. Both hold at every step below because unknown keys are inert on both sides (§ 0), so the order is chosen for who can test what, not for compatibility.

#repositorystepdepends oncost (code lines)
C0mcglobals diet: the nine src/driver.mc file-level globals into one arena record drv behind accessors (the sb_state precedent); check-inert identical; goldens move once--~70 moved, -8 globals (454 -> 446)
R1registrymigration 006; manifest.mc rows; the kind rule, the dependency-registered rule, subpath/asset, the columns, the index keys, the pages, the counters, register's two parameters; goldens re-recorded--~500 mc + 70 SQL + ~220 test (repos.py, test-job.sh fixtures fixtool, fixperm, fixsub, pages.py)
C1mcthe manifest and the lock: deps.mc reads [[permission]]/[[tool]]/bin, the canonical form, kind from [project]; pkg.mc reads the index keys into VR_*, MVS over [[tool]], the union table, the accept rule, --yes's second meaning, the lock's two keys, list columns; check-pkg.sh fixtures tool-0.1.0 (kind exe, two permissions), net-1.0.0 (a lib declaring net), locks re-recordedC0deps.mc +90, pkg.mc +170, toml.mc 0, check-pkg.sh +130; 0 globals (fields in pk/dp)
C2mcthe sandbox primitives: --rw, --at-path, --tmp, --allow=net, --bin, --env; the net and spawn deltas measured; four tests/sandbox/ cases; CI's four cells--sandbox.mc +60, sandbox_box.mc +85, seccomp.mc +25, sandbox-trace.sh +40, test-sandbox.sh +60; 0 globals (sb_state)
C3mcmc tool: src/tool.mc (install/list/remove/upgrade/run, box-args, the staging, the build spawn, the launchers, the install manifest, --unconfined), core_pkg.mc +2; scripts/check-tool.sh (Linux-only box cases self-skip elsewhere)C1, C2tool.mc ~430, check-tool.sh ~260, docs; +1 global (tl)
M1mcmclib: lib/mc.toml, release-assets.sh --libs, one release.yml step, check-pkg.sh +30; docs/reference/packages.md § 11 gains the second packageR1 deployed (for the registration); a release after M1 produces the first asset~60 TOML, ~45 sh, +1 YAML step
R2registrythe validator boxes test under the derived profile (mc tool box-args through the pinned compiler), the tool build box; MC_VERSION bumped to the release carrying C2+C3C3 releasedjob.mc +90, fixtures +120
L1mc-lsp (new)the first tool: the package, its test, the .vsix pointing at mc-lspC3 released, mclib publishedM28/M29's own specs
S7registrypublic MCP + tokens, with § 5.5's fieldsR1as priced in § 14/§ 19 (+~60 for the fields)

Totals: compiler ~830 code lines of mc (+ ~450 of shell tests), registry ~600 mc + 70 SQL (+ ~340 tests), mc repository ~140 lines of TOML/sh/YAML. Globals: 454 -> 446 (C0) -> 447 (C3); M44 steps 4-5 then have 13 of headroom under 460, which is what the diet buys.

6.2 Relative to S7, M44 steps 4-5, 1.0.0 #

6.3 Owner actions #

  1. Ratify D1-D8 below, in particular [package].bin in place of a [tool] table, --yes accepting permissions, and --unconfined for hosts without a box.
  2. mcreg-admin register for mclib (subpath=lib asset=mc-libs-{version}.tar.gz) after R1 deploys, and for mc_lsp once its repository exists.
  3. Create minicompiler/mc-lsp (public) and decide its licence string; decide mclib's licence string (the repository's LICENSE).
  4. Bump the registry's MC_VERSION to the release carrying C2+C3 before R2.
  5. Approve the permission vocabulary (five kinds, four path forms) -- § 7.1 is the argument for its size.

7. Risks #

  1. The vocabulary is too coarse or too fine. Five kinds, four path forms. net has no host or port: Landlock's net rules are per TCP port and know no host, the netns is all-or-nothing, and a host allowlist needs a proxy the sandbox does not have -- a host = "..." key would be a promise the box cannot keep, so it is refused rather than accepted and ignored. fs.* has no glob: a subtree is what a bind can express. exec NAME names one program: a shell (exec sh) is honest about being "everything". If a real tool needs finer words, the row shape has room (port, pattern) and the canonical form is a string, so the lock and the index do not move.
  2. workspace from another directory. The launcher resolves cwd; an editor sets it to the workspace folder, a shell to wherever the user is. $HOME and / are refused without --workspace; a project inside $HOME/a is still $HOME/a and nothing above it. What is not closed: a user who cds into a directory full of secrets and runs a tool with fs.read workspace granted it exactly that; the install table said so in words, once.
  3. A tool that needs exec to be useful: the LSP spawning mc build for a taught compiler. exec mc binds the worker's/host's mc read-only, gives the spawn delta and a process budget of 16 -- and mc build then runs the taught compiler inside the same box, under the same Landlock roots: it writes build/ under workspace, which needs fs.write workspace/build too. The LSP's manifest carries both, the table shows both, and a compiler module of the project's own [compiler].modules runs boxed as a side effect -- the first time M44 Risk 1's code runs under M43's box on a developer's machine.
  4. The trust model, in the words a user reads. The install table's fourth column is fixed text per kind; the last line before --yes is: these permissions were declared by each package's author and checked by the registry where the package runs a test; a library runs inside your program and is held to nothing at run time. That sentence is in packages.md § 1's trust box, in the table, and on every package page. It does not say "safe"; it says what was checked.
  5. What the registry cannot verify for libraries (§ 4.4): a library's declared set is a claim; a library with no test is unverified except for compiling; the closure rule and the hash are the mechanical holds. A library that declares nothing and, through the program that includes it, does everything is not a lie by this scheme's rules -- the scheme is about processes, and a library is not one. The page says declared, never verified, for a lib row; a tool row says verified by a test only when test is non-empty.
  6. Boxing the build (mc tool install's step 5) and mc build's taught-compiler spawn: both are M44 Risk 1's seam. Priced: mc sandbox run --keep DIR (copy /src's overlay upper out after the box, ~60 lines: a find-less copy needs the member list, which the build's usage file build/.mc-usage.toml does not carry -- so --keep lists the files the build wrote through openat notifications P already sees, ~40 more lines) and then tool install builds in the box; drv_teach wrapping its spawn in mc sandbox exec with the union of module packages' permissions on Linux, ~80 lines. Follow-up, not this milestone.
  7. --yes now means two things. A CI script that ran sync --yes accepted downloads; it now also accepts new permissions. The lock diff carries the acceptance into review; a team that wants a hard stop sets nothing in this spec -- a --no-new-permissions flag (~15 lines) is the priced answer if asked for.
  8. Two repos rows per triple changes a lookup that was one row (sql_repo_by_forge, the poll endpoint, jb_load_repo); the partial unique index keeps the invariant, and the S4c test phase pollci gains a two-row case. A registration by an ordinary account never sets subpath, so the public form's road is one row as before.
  9. The asset is not there when the validator looks (a scheduled poll between the tag and the Release's assets): treated as not ready, no attempts row, exactly like "no Release yet" (§ 24.1) -- asserted in test-job.sh with a stub that answers 404 once and 200 afterwards.
  10. Same-path binds inside the box (--at-path) put a host path into the box's tree; the report's no-host-path rule is about the report, which prints what the program passed and the fixed vocabulary, and stays true. What changes is that a tool can now tell where it is -- which an LSP must, and which the empty netns and the two walls do not care about.
  11. Windows launchers are .cmd files calling mc tool run, and Windows has no box: every Windows install is --unconfined. The manifest records it and mc tool list prints unconfined; the honest cell for a Windows CI job is "installs, runs, accepted without a box".
  12. The globals ceiling: C0 first, always. Without it C3's one global lands at 455 and steps 4-5 of M44 hit 460.

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

9. Acceptance (runnable; no network anywhere in make check) #

Compiler (scripts/check-pkg.sh, scripts/check-tool.sh, scripts/test-sandbox.sh):

  1. C0: scripts/check-inert.sh build/mc1.pre build/mc1 identical over the corpus; check-limits reports globals <= 446/512; goldens rewritten once after an empty --dump-asm diff and cmp build/mc2.o build/mc3.o.
  2. C1: a fixture tool-0.1.0 ([project] kind = "exe", bin = "hello-tool", [[permission]] fs.read workspace + net) and net-1.0.0 (a library declaring net); mc pkg sync tests/pkg/perm --registry $tmp/registry --libs-dir $tmp/l prints the permission table with the two fixed sentences and nothing was downloaded: re-run with --yes to fetch and to accept the permissions above, exit 0, no manifest written; with --yes the lock is byte-identical to mc.lock.expect carrying permissions = ["fs.read workspace", "net"] sorted and kind = "tool"; a second sync with nothing to fetch needs no --yes; replacing the index's net-1.0.0 row by a net-1.1.0 row that adds exec sh and raising the minimum makes sync stop without --yes and print only the changed package; mc build of the project takes no extra open and registers no root for the tool (the object is cmp-identical to a build with the [[tool]] rows removed).
  3. C1: every tests/pkg/*/mc.lock from before the change still builds (kind absent = lib, no permissions = empty accepted set); mc pkg check of an index row carrying kind/permissions/tools/licence exits 0 with --yes and the row is re-derived from its archive; a [[permission]] row with path = "/etc" or path = "workspace/../x" is refused at the key's file:line:col; a manifest with [project] and no kind is refused by mc pkg check --yes with a package's [project] must say kind.
  4. C2: tests/sandbox/{rwtree,nettrip,binexec,envread}.mc each with an .expect per (allowed, refused) pair, 10/10 -> 18/18 on both architectures, both privileges; scripts/sandbox-trace.sh --check clean with the net and spawn lists in both directions; two runs of every case byte-identical reports; the CI sandbox job green in its four cells.
  5. C3 (Linux cells; self-skip elsewhere with the reason): mc tool install hello_tool --yes --registry $tmp/registry --libs-dir $tmp/l --bin-dir $tmp/bin prints the plan and the table, stages $tmp/tools/hello_tool/v0.1.0/{mc.toml,deps/,mc.lock,build/hello-tool}, writes v0.1.0.toml last, writes $tmp/bin/hello-tool (two lines); $tmp/bin/hello-tool prints hello from a file under the workspace and exits 0; the same tool asked to read ../secret is sandbox: refused: open ..., exit 125; without --yes nothing is staged; mc tool list matches a golden; mc tool remove unlinks the launcher and the manifests; on macOS the same install without --unconfined exits 2 naming the flag, with it installs and runs.
  6. C3: mc tool box-args tests/pkg/src/tool-0.1.0/mc.toml prints exactly --ro <cwd> --at-path, --allow=net (golden); the string is what check-tool.sh feeds mc sandbox exec directly, proving the two roads agree.
  7. M1: mc pkg hash lib equals scripts/pkg-hash.sh lib on macOS, Linux and Windows CI; lib/mc.toml's files equals the generated list (drift fails); scripts/release-assets.sh --libs 0.0.0-dev dist twice gives cmp-equal tarballs; mc pkg sync of a fixture project pinning mclib = "0.0.0-dev" against a directory registry whose row's url is that tarball builds a program including <mclib/prelude.mc> and <mclib/float_rt.mc>, and the object is cmp-identical vendored and cached.
  8. Inertness at every step: check-obj 32/32, check-inert identical, check-standalone, check-parts (the <mc/core_pkg> row grows by tool.mc; a compiler without the part prints no tool usage line), check-docs green with every new key in docs/reference/toml.md (the gate counts them: 27 today) and every new flag in cli.md.

Registry (make check: test-repos, test-job, test-pages, test-admin):

  1. 006 applies on a database at 005; db/schema.sql regenerated; versions_immutable() proved in SQL to refuse an UPDATE of kind, permissions, tools, bin, licence, job_id.
  2. test-job.sh fixtures: fixtool (kind exe, bin, two permissions, a test that reads its workspace) publishes with kind = tool; the same with a test that opens /etc/passwd is refused: the checks did not pass with sandbox: refused: open /etc/passwd in the report and nothing published (Linux cell; the stub cell asserts the derived flags reached the sandbox stub's argv); fixproj (a [project] with no kind) is refused with the sentence and an attempts row; a second version of fixtool with kind = "obj" is refused for changing kind; fixdep requiring an unregistered name is refused naming it; fixsub (subpath=lib, asset=x-{version}.tar.gz) hashes the subtree, is skipped with no attempts row while the stub answers 404 for the asset, and publishes with the asset URL once it answers 200.
  3. pages.py: /packages/mc_lsp shows the kind badge, the permission table with the reason, dependencies and dependents (a fixture depending on it), the versions table with the report link, the two counters with their names; /packages?kind=tool lists only tools; web/tests/registry/*.toml goldens carry the four new lines per row and mc pkg check (the pinned compiler) accepts them.
  4. reg_index writes one fetches row per package per day, n increments across two GETs of the same file and the alias, and a 404 writes nothing.
  5. test-admin.sh: register with subpath/asset creates a second row for a triple already registered at the root; without them a second registration of the same triple is still already registered.

Implementation notes -- C1 (the manifest, the install table and the lock) #

Written after the work, from what was measured. C1 is § 1, § 4.2, § 4.3 and § 1.8 of the design above, plus acceptance § 9 items 2 and 3. C0 was PR #42; C2, C3 and M1 are not built.

1. Cost. git diff --numstat src/: deps.mc +291/-7, pkg.mc +497/-37; of the added lines, 198 and 382 are neither a comment nor blank. The design priced +90 and +170. Three things account for most of the excess and none of them is new mechanism:

The rest is the manifest reader (dep_perm_line, dep_read_perms, dep_kind_of, dep_bin_of, the two name rules) and the tool's exclusion from the root table. One thing got SHORTER: pkg_name_lt is now dep_str_lt, so the order a lock's rows are sorted in and the order a permission set is sorted in are one function and cannot disagree about what sorted means.

Globals: 443/512, unchanged. Every table added is a field in dp (PK_KIND, PK_NPERM, PK_PERMS, PK_ROOT) or in pk (PKS_NACC, PKS_ACC, PKS_LONG, and seven VR_* columns).

2. The tool is excluded from the root table, not given an empty root. dep_read_lock used to register one include root per lock row, index-parallel with the package table, and dp_set_dir(i) wrote lex_set_root_dir(i). A tool row must have no root at all -- with one, lex_root_first would match its NAME and libs_open would join a directory that was never resolved -- so the two tables stop being parallel and PK_ROOT is the map, -1 for a tool. Edges whose end is a tool are dropped. deps_apply skips a tool row whole: no dep_resolve, no dep_scan, no hash. Measured: tests/pkg/perm builds to a byte-identical object with the [tools] table deleted and with the tool's tree deleted from <libs> (check-pkg § 31e).

3. [tools] is validated by mc build and resolved by mc pkg. deps_apply checks the NAME at its own position, exactly as it does for [replace], and does nothing else: a project whose only dependency table is [tools] reads no lock and opens no file. pkg_read_deps is what turns the rows into MVS requirements, with SL_TOOL marking which table asked -- that mark is the lock's kind and the "tools required by this project" block, and nothing else.

4. Deviation: the permission block is printed only when something with a non-empty set is unaccepted. § 4.2 says "one block per package whose set is not already accepted", and § 4.3 makes a row absent from the old lock unaccepted. Read literally, the FIRST mc pkg sync of a plain library project -- every existing project, every fixture -- would print (none: stdio only) for every package and end with "to accept the permissions above", where there is nothing to accept. So the rule implemented is: the block appears when at least one unaccepted set is non-empty, and it then lists every unaccepted row, (none: stdio only) included, which is exactly § 4.2's example. An empty set therefore never turns a silent sync into one that needs --yes. Consequence, measured: tests/pkg/sync's plan is byte for byte what it was before this milestone.

5. The manifest is validated before the tree is blessed. pkg_fetch_one calls pkg_read_meta -- kind, bin, permissions, all refusals live -- after the hash comparison and before pkg_write_manifest. A refusal there leaves no cache manifest, so the tree is is not fetched and not "a package that says something impossible". check-pkg § 31h asserts the absence of v1.0.0.toml for all four refusals.

6. The kind is asked before anything else about a row. fs.delete was reported as fs.delete takes no path in the first draft, because the per-kind rules ran before the vocabulary check. It is now a permission kind is fs.read, fs.write, net, exec or env, at the kind key.

7. mc pkg check compares five more things -- kind, bin, licence, permissions, tools -- because a row is what a consumer reads before fetching: an under-declared row asks for consent to the wrong thing. Both permission sets are canonical and sorted, so equality is position by position and needs no search. Proved in both directions in check-pkg § 31g: the fixture row passes, and a row with permissions = ["net"] over an archive that asks for two is the row's permissions are not the archive's, exit 1.

8. The two goldens, and why each line moved. tests/pkg/sync/mc.lock.expect: every key of every row is re-padded from 8 to 11 columns (the width of permissions), and each row gains permissions = [] -- the empty ACCEPTED set, which is not the same claim as a row that predates the key. Hashes, versions, order: unchanged. tests/golden/pkg-list.txt: each of the two lines gains one word, stdio. vendored is exactly 8 characters, so the new road column's padding adds nothing and the rest of the line does not move. tests/pkg/perm/mc.lock.expect is new. The four hand-written locks in tests/pkg/*/mc.lock were deliberately not re-recorded: they are the compatibility case (§ 1.8), they still carry neither kind nor permissions, and check-pkg asserts that they do not and that the chain builds with them.

9. What C1 does not do. It does not install, build or run a tool -- mc tool is C3 -- so a [tools] row is fetched into <libs> like any package and recorded, and nothing more happens. It enforces no permission: on this side a permission is read, printed, compared and recorded. It adds no mclib (M1) and touches no sandbox (C2).


Implementation notes -- C2, the windows/x86_64 finding #

C2 was green on macOS, on all four Linux cells and on mc on windows/arm64 host, and red -- twice, deterministically -- on mc on windows/x86_64 host: the cross-compiled compiler linked, answered mc --host correctly, compiled a small program, lexed and PARSED its own source (--dump-tokens, --dump-ast, exit 0 each), and then died with no output at all while COMPILING it. The defect is not in anything C2 wrote.

1. What the static pass said. Cross-building the windows/x86_64 compiler object from origin/main and from the branch and diffing --dump-asm --machine=x86_64-win of src/mc_windows_x86_64.mc (normalising the l_strN indices, which renumber): 1712 functions in common, 53 different, all of them sb_*, plus 33 new ones. Per-symbol disassembly of the two COFF objects agrees: every core function's machine code is byte for byte identical. sub rsp, N maxima identical (1152, under a page -- no __chkstk question). Section sizes, symbol counts and relocation counts all far from any ceiling (14457 relocations against the 65535 sentinel). So the compiler's own code did not change; only its data and the source it was fed.

2. The oracle. No Windows machine here, but wine under --platform linux/amd64 runs the binary: the main compiler reproduces its CI success (exit 0, mc-windows-x86_64.obj 1388708 bytes, the CI byte count) and the C2 one reproduces the failure in 1.1 s. WINEDEBUG=+seh named it -- EXCEPTION_STACK_OVERFLOW, c00000fd, stack 0x20000-0x21000-0x820000, i.e. all 8 MiB gone. Two cross runs then settled where the fault lives: the C2 binary compiles main's source (exit 0) and main's binary crashes on C2's source (exit 1). It is the INPUT, not the code.

3. The cause. fold (src/parse.mc) recursed on nd_next:

    i64 nx = fold(nd_next(n));
    set_nd_next(n, nx);

A sibling chain is a list, and a global array initializer is one node per element -- so the stack depth was the LENGTH of the longest list in the program. src/bundle_data.mc is exactly that shape: the M21.5 deviation keeps u64 bundle_blob[] = { ... } on disk, because the frozen seed's lexer has no #embed. main: 71739 elements. C2, whose bundle carries the new sandbox sources: 73045. fold's frame is 64 bytes of locals; per level that is 8 (return address) + 8 (saved rbp) + 64 + 32 (the Win64 shadow space, held across the recursive call) = 112 bytes, against 80 under AAPCS64 and System V, which have no shadow space. 71739 x 112 = 7.66 MiB, fits; 73045 x 112 = 7.80 MiB plus the callers, does not. fold runs on the compile road only -- src/cli.mc returns for --dump-ast on the line above it -- which is why the parse smoke passed.

Measured ceilings with a generated u64 big[] = { ... } and nothing else in the file: the windows/x86_64 compiler took 74000 elements and failed at 75000; the linux/x86_64 one took 100000 and failed (SIGSEGV) at 120000; the macOS arm64 one failed at 150000. Every host had the same defect; the 40% bigger Win64 frame is only what made it arrive first.

4. The fix, src/parse.mc, +30/-16 with the comment, 8 code lines: fold walks the SIBLING chain with a loop and keeps the recursion for the CHILDREN, so the depth is the nesting and nothing else. fold always answered the node it was given, so set_nd_next(n, nx) was a no-op and the loop's shape is the same traversal in the same order. After it: 400000 elements compile on windows/x86_64.

5. The gate. scripts/check-mc.sh generates a 150000-element chain, compiles it with the host compiler, links it and runs it (exit 42). Generated and not committed because 150000 elements is 300 KB of source; it is the one program in that script the repository does not carry. It is there and not in tests/windows/ because that corpus is cross-compiled on macOS and only LINKED and RUN on Windows -- it would never exercise the Windows-hosted compiler's stack. check-mc is in the Windows and Linux make check subsets, so the case runs on every host. Proved to have teeth: with the pre-fix compiler it is FAIL long-list (compilation: ) -- the empty message is the SIGSEGV.

6. Not done, on record. The linker's default 8 MiB stack reserve is left alone (scripts/link-windows.sh passes no -stack:). Raising it would have hidden this one instance on one host and left the defect -- a compiler whose stack depth is a function of a list's length -- everywhere else. docs/reference/objects.md § 4c gains the frame-cost paragraph so the next person measuring a Windows-only stack failure starts 40% ahead.

Implementation notes -- C3 review (the hostile-tool escalation) #

The review found that a malicious tool package (its mc.toml is fetched from a registry, so attacker-controlled) could escalate its sandbox grant past what the user accepted, by two independent holes, plus three lower-severity issues. All five are fixed in src/deps.mc, src/pkg.mc and src/tool.mc; stage0/ is untouched. Each was reproduced against the pre-fix compiler first (scripts/check-tool.sh § 6 carries the cases so they cannot regress).

1. Unescaped TOML re-serialization (BLOCKER, finding 1). mc tool install wrote [project].out -- read verbatim from the tool's tree -- into ~/.mc/tools/<name>/v<ver>.toml with no escaping. TOML basic strings decode \" and \n, and src/toml.mc has no duplicate-table check, so out = "app\"\n[tool]\npermissions = [\"fs.write home/../../..\"]\n#" spliced a SECOND [tool] table -- with its own permissions -- into the installed manifest; toml_count / toml_get_array then returned every occurrence, and the spliced write was granted at run. Reproduced: the pre-fix manifest carried two [tool] tables and permissions = ["fs.write home/../../../../tmp/pwned"]. Fixed with toml_esc(s) (src/deps.mc): it doubles \ and ", escapes \n/\t/\r and refuses any other control byte, and is applied to every free-text value the three TOML writers embed -- tool_write_manifest (out above all, and name, bin, sha, each permission), pkg_write_manifest (name, version, url, sha, each file path), pkg_write_lock (name, version, bin, lib, the path of a [replace], each dep name, each permission, the hash) and pkg_dep_line (the version). The sweep found no other place this compiler generates TOML from fetched input; the sysroot writers emit .def/.tbd, and limits.mc and the generated-compiler writer emit their own numeric/name data.

2. tool_run trusted the stored permissions (BLOCKER, finding 2). It read tool.permissions back and handed each to the sandbox-flag mapper with no validation, and tool_resolve_path's fallback turned an unrecognized string into the whole workspace, writable. Combined with (1), an injected fs.write home/../.. was applied though it was never shown by the install table, never gated by --yes, never containment-checked. Fixed two ways: tool_run now runs every stored line through dep_perm_line_ok (src/deps.mc, the inverse of dep_perm_line -- 1 exactly when this compiler could itself have written the line) BEFORE any flag is mapped, on every host, and refuses the install manifest carries an invalid permission: <line>; and tool_resolve_path's fallback is now a hard error, never a default. Reproduced: a hand-corrupted manifest with fs.write home/../../tmp/pwned ran unrefused pre-fix and is refused post-fix.

3. version became a path unchecked (finding 3). [[versions]].version (and a lock or installed-index version) was concatenated into <libs>/<name>/v<version>/ and ~/.mc/tools/<name>/v<version>/ with no charset check, so version = "../../../../tmp/evil" staged a tree outside the package root (reproduced: into <libs>/vt/v../../../../tmp/evil/). Fixed with dep_ver_ok (SemVer charset, non-empty, no leading dot, no ..) enforced at the row in pkg_index_read and in dep_read_lock (each toml_err_key, at the key), and a belt-and-braces dep_ver_path guard the four path builders call the instant a version becomes a path.

4. Capacity mismatch (finding 4). DEP_MAXPERM (32) exceeds the box's per-kind ceilings (SB_MAXRO/SB_MAXRW 16, SB_MAXBIN/SB_MAXENV 8), so a set of e.g. 20 fs.read rows was accepted and locked at install and then refused at every mc tool run. tool_check_capacity (src/tool.mc) counts the per-kind roots the set maps to and refuses at install, before the user is asked to accept it (too many fs.read permissions for the box: 20 (at most 16)). The ceilings are copied into tool.mc with a comment, not shared from sandbox.mc, because <mc/core_pkg> is compiled before <mc/core_sandbox> and mc-slim omits the sandbox; scripts/check-tool.sh drives a set past fs.read so the two cannot drift silently.

5. The (none) row (finding 5). A tool that declares no permission now boxes with its own install tree bound read-only (--ro <tree> --at-path), matching § 4.1's row and docs/reference/tools.md. Implemented in tool_run for the zero-permission case. On record: mc sandbox exec always overlays the binary's own directory as /src, a copy-on-write overlay, so the tool's current directory is technically writable; but every write lands in an ephemeral upper layer on the box tmpfs, the host tree is never touched, and the writes are discarded when the box exits. A literally read-only /src for exec is a future sandbox mode and was not built here (it would need a new box mount mode, and would otherwise destabilize the frozen M43 sandbox behavior). The security property a zero-permission tool must have -- it cannot reach your files and cannot persist anything -- holds today; scripts/check-tool.sh proves boxed that it cannot create a host-visible file, and the residual is documented in docs/reference/tools.md § The one mapping.

NIT (finding 6). scripts/check-surface.sh silently fell back build/mc_seed -> build/mc1 when the seed compiler was absent. It now prints which compiler it used and warns loudly on the fallback, since a missing mc_seed in CI is a regression, not a benign substitution.

Implementation notes -- C3 review, the consumer's four (0.16.1) #

Four defects reported by the consumer (teko) from a real run against 0.16.0, each with a pure reproducer, each fixed at its root. scripts/check-tool.sh § 7 is one case per defect, 27 -> 31, and all four FAIL against the pre-fix compiler (27/31).

1. mc tool install DIR ignored [tools] when the project had no [deps]. deps_apply (src/deps.mc) counted deps. keys alone and returned on nd == 0 BEFORE dep_read_lock, so dp_npkg() was 0 and tool_install_project printed no tools required by this project, exit 0 -- with a valid lock naming the tool sitting beside the manifest. Adding one [deps] row to the same project made it install, which is what the consumer measured. The lock is the answer to BOTH tables, so a tools. key counts towards nd and the lock is read when EITHER table has rows. One root, every consumer: mc build, mc pkg list|verify|vendor and mc tool install DIR all reach the lock through this one function (pkg_read_deps, the mc pkg sync road, already counted tools). The consequence, documented: a [tools]-only project now needs an mc.lock for mc build too, and without one says mc.lock is stale with its run: line, exactly as a [deps]-only project does. Inert for every fixture in the tree -- tests/pkg/perm is the only one with a [tools] table and it has [deps] beside it, so nd was never 0 there.

2. A [replace]d tool was half-applied. The tool was fetched from the registry and then tool_stage's pkg_write_lock resolved the project's [replace] path against the STAGED tree -- tool_stage repoints cfg_file() at ~/.mc/tools/<name>/v<ver>/mc.toml so the vendored deps/ is found -- and died mc: cannot open: <tools>/<name>/<path>/mc.toml after the download. Reproduced verbatim with [replace] hello_tool = "../toy". The rule chosen is IGNORE, and the spec supports it: [replace] is Go's replace (M44 D11), an override for a tree the BUILD compiles, and deps_apply step 4 already skips every tool row before dep_replace is consulted -- a tool is not compiled into your program, it is a program installed under ~/.mc/tools at the version the lock pins. Honouring it would have meant a second, fetch-free install road for an unpinned tree with no hash to record, contradicting the row the build already skips. So the whole mc tool road ignores the table -- for the tool AND for the libraries staged beside it, which tool_stage copies from <libs> and not from a replace path -- and announces it once per replaced name in the build list, above the plan: note: [replace] <name> = "<path>" is ignored by mc tool: a tool is installed from the registry. Nothing dies after a download.

3. A constraint mc pkg sync refuses reached the install road. deps_apply validated a deps. value with ver_parse (step 3) and a tools. value not at all, so [tools] x = "^0.1" was accepted, fetched, staged and BUILT, and the install table printed the nonsense x >= ^0.1. It is now the same ver_parse + ver_bad_msg() at the key's own file:line:col, exit 2, in step 1 -- before the lock is read and before anything is fetched. It is in step 1 and not step 3 because step 3 compares a constraint against a lock row and a tool's version comes from the lock: the only thing to say about ^0.12 is that it is not a constraint.

4. The permission table lost its column gap. pkg_pad(s, w) pads to w and no further, so a value at or past the width got nothing: a 24-byte fs.write workspace/build in a 22-wide column ran into the sentence -- fs.write workspace/buildmay create, change and delete files under build .... The what column has the same shape (<name> <version> passes 17 bytes on a long name). Fixed where every OTHER caller already puts it: mc pkg list and mc tool list write their own space after each column, and the permission table now pads to w - 1 and writes that space -- the same total width for every value that fits, a guaranteed gap for one that does not. The first draft forced the space inside pkg_pad instead and it moved an unrelated golden: mc pkg list's hash column is xstrdup(h, 12) in a 12-wide column, exactly at its width, so every row of tests/golden/pkg-list.txt gained a space (measured: check-pkg 192/194). The helper is back to what it was and the goldens are byte for byte.

The seed's MAXFUNCS is what shaped the code. lib/mc_i128.mc is #include "../src/core.mc" plus a module and sat at 2046 of the frozen seed's 2048 functions; the first draft added three (pk_norepl, pk_set_norepl, tool_note_replaced) and build/mc0 --dump-asm lib/mc_i128.mc became mc: too many functions, which check-asm compares byte for byte and stage0/ may not be edited to fix. Collapsed to one: tool_ignore_replace() prints the notes and then sets the flag itself (idempotent, so a two-tool project says it once), and pkg_replace_path reads ld64(pk_state() + PKS_NOREPL) where the record is declared -- the shape pkg_list already uses for PKS_LONG. 2047 of 2048: one function of headroom for the whole repository. (The seed exhausting its 64 MiB arena on build/mc0 lib/mc_i128.mc -o x.o predates this change and no gate takes that road -- check-lex/check-ast/check-asm only dump.)

Edit this page