Spec M53 -- the surface freeze, the deprecation policy and the release canary: the road from 0.16.0 to RC 0.17.0 and to 1.0.0
Written against minicompiler/mc main at 5dd6c90 (newest tag v0.16.0), read-only session
except for the extractions of § 1, 2026-09-14. ../plan.md's row is written from this
spec and not the other way round.
The owner's direction, verbatim:
Congelar a superfície — RC 0.17.0 — crítico. Política de deprecação (hooks.md § Stability): de 1.0.0 sem rename seco — adiciona novo + alias por um major, remoção = MAJOR. Formatos, mc.toml/mc.lock, nomes
<mc/*>e a versão congelados = semver de verdade.
Melhorias + canário — antes de cortar: a release do mc dispara o CI da teko antes de publicar (bloqueante no 1.0.0). Sem credencial — a teko puxa a release pública do mc.
A 1.0.0 sai quando a lista de lacunas da teko chega a zero — ela auto-hospeda nas 5 pernas e publica a teko_std sem pedir gancho novo ao mc.
The one-paragraph version. The measurements move this milestone twice. First, the inventory
already exists and nobody reads it as one: scripts/check-docs.sh extracts the whole public
surface from src/ on every make check -- 206 symbols, 50 CLI flags, 35 TOML keys, 10 directives
-- and then throws it away after asking "is each one documented?". Asking the second question, "was
each one here last time?", costs one recorded file and one comparison. Second, the extraction has
a hole and the hole is exactly where a freeze would hurt most: val_reg, dst_reg and dst_done
are the three names ../reference/machine.md § 3 publishes as the only
three names of a machine's internals that are frozen (contract version 3, M24 D2), and they match
none of the 17 prefixes and none of the 5 exact names the extraction knows -- 0 of 206. A gate
built on today's regex would have frozen 206 names and left the three that were already promised
unprotected. So M53 is not a policy document with a script attached: it is one shared extractor
(scripts/surface-extract.sh, the single definition both gates read), one recorded golden
(tests/golden/surface.txt, 420 lines across seven kinds), one gate (check-freeze), the
policy text hooks.md has never had, and a promote job that turns every release into a GitHub
pre-release until a public verdict file teko writes says it built. src/ is untouched, stage0/
is untouched, and not one golden moves -- which is what makes a freeze milestone safe to land
between a minor and an RC.
0. What this stands on (read, not assumed) #
- The extraction is already there, and it is already the inventory.
scripts/check-docs.shbuilds four lists at run time and compares each againstdocs/reference/: public symbols (:89, onegrep -hoEoversrc/*.mcwith 17 prefixes --p_ syntax type_ pass backend machine sec_ sym_ reloc_add gen_ on_ decl_ host_ intrinsic walk_ subcommand c_-- and five exact names,parse_unary parse_top do_directive lex_include source_claim), CLI flags (:112, the literals inside astr_eq(x, "--flag")oropt_val(x, "--flag="), plus-o), TOML keys (:130and:132, the literals insidetoml_get|get_array|count|int|bp|err_key, plus the two table prefixes it walks) and directives (:149, the names insrc/lex.mc'sdir_index(), "which IS the table"). Its own header states the principle this milestone extends: "The lists are extracted fromsrc/*.mc, never written down here." - There is no § Stability in
docs/reference/hooks.md. Its seven sections are When each hook runs, Tier 2, Tier 3 and Tier 4, The parser's public API, A worked example, The host layer, M41 -- the composable core.grep -rn 'deprecat' docs/ src/ scripts/finds two hits in the whole repository, both about a macOS API insrc/sandbox.mc:902. Nothing anywhere states a compatibility promise, a deprecation lane, or what a major means. - The machine contract is already a versioned surface, and its discipline is the model.
../reference/machine.md:1 carries Contract version 5 and a four-paragraph changelog: "A version is the list ofMTASK_*slots below, in that order and with those signatures, plus what a machine outsidesrc/may rely on. Adding a task appends a slot and bumps the version; changing one's signature is a breaking change to every machine." Versions 2, 3 and 4 each appended nothing and bumped anyway -- 3 for what a module may rely on, 4 for an OBLIGATION. Version 5 appended six slots with the null-slot rule so that every machine written against 4 emits byte for byte what it emitted, with no edit. That is append-only compatibility, already practised, already documented, on exactly one of the seven kinds below. - The tags are the only source of truth for the version, and pre-releases are refused in two
places on purpose.
../ci.md§ Versioning: "Versions are plain semantic versions,X.Y.Z. No pre-releases:release.ymlstill marks a--suffixed tag as a GitHub pre-release, but neitherautotag.ymlnortag.ymlwill make one, andscripts/next-version.shrejects0.2.0-rc1with a message that says so."scripts/next-version.sh:17-19and:206are the refusal;.github/workflows/tag.yml:52-56is the second one, with its own error text. release.ymlalready knows how to publish a pre-release, and it is three lines::647-657,case "$VERSION" in *-*) prerelease="--prerelease" ;; esacbeforegh release create. The assets, the checksums and the body are attached either way.publish-to-registryis the precedent for a post-publish job..github/workflows/release.yml:685,needs: publish,continue-on-error: false, gated byvars.MC_REGISTRY_PUBLISH, and its comment states the rule this milestone reuses: "a failed announcement must not be able to unmake the release, and a rerun of this job alone is the retry." It carries no secret -- the whole request is the repository's public URL.- The baked version is a sentinel and a gate keeps it one.
src/version.mc:33isuptr mc_version() { return "0.0.0-dev"; };scripts/check-bundle.sh:94asserts that exact line and failsmake checknamingset-version.sh. So the working tree cannot know what version it is, which is why § 5's deprecation marker carries a version a human writes and not one a script derives. - The goldens' discipline (
../../tests/golden/README.md): a recorded file, compared on every run, rewritten only on purpose and only after its own criterion, in the same commit as the change that moved it. Ten of them today. "So these files move only whensrc/*.mc,lib/*.mcor the codegen change on purpose, as they always have." - The consumer has already frozen its side.
/Users/schivei/mc-teko/teko-to-mc.md, 2026-09-08 23:40: "Pelo dono: a API de hooks não muda mais (bugs reais do core só), então a teko a trata como estável e o meu roadmap deixa de listar 'API congelada' como dependência." And, from the same entry, "nada mais pende do mc para a teko". What teko registers, measured by grep over both interface files:syntax_expr((7),machine((3),target((1). What it includes from the bundle, its own 2026-09-13 measurement:<sys>,<mc/core_writers>,<mc/core_machines>,<mc/core_bundle>,<mc/core_build>,<machine_x86_64_float>,<machine_arm64_float>,<float>, and[compiler].core = "<mc/core_min>". - M52 kept the map whole.
tools/bundle.liststill has 101 rows after the cut; 41 of them are served fromlib/mc/v<ver>/beside the binary instead of from the blob (M52.md§ 2, last paragraph: "the map and the package keep all 101/105 rows"). A#include <name>resolves the same spelling either way, so the name is surface and the road is not. src/has 448 file-level globals, counted withgrep -hoE '^(i64|uptr|u8|u16|u32|u64) +[a-z_][a-z_0-9]*( *=|;|\[)' src/*.mc | sed … | sort -u. The memory rule the owner recorded (globals are module API) comes from teko hitting a rename at 0.15.12. § 3 decides what to do with them, and the number is why.
1. The measurements (step 0), and what they decide #
1.1 The extraction today, counted #
Running check-docs.sh's own four pipelines by hand on this tree:
| kind | source of truth | count |
|---|---|---|
sym | src/*.mc, the 17 prefixes + 5 exact names (check-docs.sh:89) | 206 |
flag | str_eq/opt_val literals in src/*.mc, plus -o (:112) | 50 |
toml | toml_*("literal") + the two walked prefixes (:130, :132) | 35 |
dir | dir_index() in src/lex.mc (:149) | 10 |
The symbol families, largest first: gen_ 46, p_ 25, host_ 20, backend_ 17, sec_ 15,
type_ 12, syntax_ 12, sym_ 11, machine_ 9, walk_ 7, intrinsic_ 6, decl_ 6, on_ 4,
subcommand_ 2, and the seven that are words on their own or exact names.
1.2 What the extraction does NOT see -- four holes, measured #
a. Three names that were already promised. docs/reference/machine.md § 3 ("The allocator
functions a task handler may call", and version 3's changelog entry) publishes val_reg(d, scratch),
dst_reg(d) and dst_done(d, reg) as the only three names of a machine's internals that are
frozen. docs/reference/hooks.md:546 repeats it. All three are defined in src/machine_arm64.mc
(:161, :181, :186), are read by lib/machine_arm64_float.mc, lib/machine_x86_64_float.mc,
lib/i128.mc, examples/avx/avx.mc and examples/kernel/machine_riscv64.mc -- and match none
of the 17 prefixes and none of the 5 exact names. grep -cx val_reg against the extracted list:
0. This is the found defect, and by the owner's rule it is fixed, not voted: step A adds three
exact names and the inventory becomes 209. check-docs stays green without a doc edit, because
all three are already written as calls in the reference (machine.md:215, :762, :763).
b. The lock and index row keys are invisible. mc.lock's [[package]] rows and the registry
index's [[versions]] rows are read with toml_get(tm_cat(key, "version")) -- a computed path, not
a literal -- so check-docs.sh:130's regex cannot see one. Extracted separately from
tm_cat(key, "...") in src/pkg.mc, src/tool.mc and src/deps.mc: 14 keys -- bin deps kind
lib licence name path permissions sha256 strip tools url version yanked. These are a FORMAT written
by one version and read by another and by the registry; the direction names mc.lock explicitly.
They become the seventh kind.
c. 448 globals, of which a handful are genuinely read from outside src/. The naive
word-boundary grep over lib/, examples/, tools/ and site/gen/ gives 30 candidates, most of
them false positives (cp, pe, heap, nodes, locals, sections, symbols are ordinary local
names in unrelated files). The ones verified by reading the hit: a64_out
(lib/machine_arm64_float.mc:503), x86_shadow/x86_nargreg/x86_argreg
(lib/machine_x86_64_float.mc:283-311, lib/i128.mc), x86_tmp (examples/avx/avx.mc),
walk_fn_ret (lib/i128.mc:627), nnodes/nodes (lib/pass_demo.mc:5-17), ty_i32
(examples/avr/mc-avr.mc:70), bundle_blob (tools/bundle.mc). Roughly ten of 448. § 3
decides.
d. Diagnostic text. docs/reference/diagnostics.md documents 373 rows across 15 sections.
The number a gate actually asserts, counted over scripts/*.sh: 19 distinct mc: … literals,
of which check-surface.sh's 18 err_case calls are most. So 95% of the messages are documented and
protected by nothing. § 3 decides.
1.3 What the release pipeline does with a candidate today #
Measured by reading, not by cutting a tag: autotag.yml and tag.yml both refuse a - suffix, so
the only way to a pre-release version string is a hand-pushed tag, "deliberately outside the
automation" (../ci.md § Versioning). release.yml handles such a tag correctly
(:649). And a pre-release version would then be skipped by the compiler's own resolver:
pkg_newest/pkg_pick take a pre flag that is 0 for mc pkg add NAME and for a plain
mc update (CLAUDE.md § State, the 0.15.1 entry: "a candidate is never chosen for you"), and
mc upgrade resolves through the same row. So a 1.0.0-rc.1 is a version nobody would install,
which is the opposite of what a release candidate is for.
1.4 What that decides #
- The inventory is not new work: it is the four pipelines that already run, plus three exact names and one more grep. One extractor, read by both gates -- otherwise § 11.3's drift risk is real and the two regexes part company on the first edit.
- The freeze has to cover names the extraction cannot see today (1.2a, 1.2b), and must not try to cover 448 globals or 373 message texts (1.2c, 1.2d) -- see § 3 for the arguments, which are different in each case.
- "RC" cannot be a version suffix (1.3). It is a state of the repository between two ordinary tags, and the candidate semantics live in the GitHub release's pre-release flag, where the canary can act on them. § 7.
- The canary needs nothing built for it on the mc side that does not exist: a pre-release flag
(
:649), a post-publish job with no secret (:685), and a public URL to read.
2. What "the surface" is -- decision (design question 1) #
Seven kinds, each with a single source of truth in the tree, none written by hand.
| kind | is | source of truth | today |
|---|---|---|---|
sym | a function a module outside src/ may call | src/*.mc, surface-extract.sh's prefix + exact-name list | 209 |
flag | a command-line option mc accepts | the str_eq/opt_val literals in src/*.mc, plus -o | 50 |
toml | a key mc.toml may carry | the toml_*("literal") calls and the two walked prefixes | 35 |
dir | a #directive | dir_index() in src/lex.mc | 10 |
bundle | a name #include <…> resolves | column 1 of tools/bundle.list | 101 |
lock | a key of an mc.lock or index row | the tm_cat(key, "…") literals in src/pkg.mc, src/tool.mc, src/deps.mc | 14 |
machine | the machine task contract's version | the Contract version N line of docs/reference/machine.md | 1 |
420 lines. Notes on the three that are not simply "what check-docs already extracts":
bundlecovers the<mc/*>names AND the parts, in one kind. A part is named exactly the way a library is --[compiler].core = "<mc/core_min>"is the string teko writes, and it istools/bundle.listcolumn 1. Giving parts a kind of their own would be a second spelling for one string. 57 of the 101 aremc/…; M52 moved 41 of the 101 out of the blob and intolib/mc/v<ver>/without changing a single name, which is the proof that the name and not the road is the surface.lockis a format, and the direction names formats. Its 14 keys are what one version writes and another reads, and what the registry's validator reads too. A key silently dropped from the writer is a lock an oldermccannot use; a key dropped from the reader is a lock a newermcignores. Both are the gate's business.machineis one line, not 37. The contract already versions itself with an append-only rule and a written changelog (§ 0). Listing 37MTASK_*names would duplicatemachine.mdbadly -- the contract's promise is about order and signatures, which a name list cannot express. One line,machine<TAB>5, and the rule is: the number may only go up, and only in a commit that adds a version paragraph tomachine.md. A DOWN move or a silent change failscheck-freeze.
mc.toml and the registry index row are covered by toml and lock between them. mc --version's
output shape is covered by flag (--version) plus § 5's behaviour clause.
3. What is deliberately NOT the surface -- decision #
Three exclusions, each with its own argument. This section exists because a freeze that says "and everything else too" is a freeze nobody can land.
a. File-level globals in src/ are NOT API. 448 of them (§ 0), and freezing the set would make
every internal of the compiler permanent: M48 C0's diet -- twelve src/driver.mc globals folded into
one arena record so that check-limits stayed under the frozen seed's MAXGLOBALS -- would have
been a MAJOR, and the seed's globals row is the tightest in check-limits precisely because that
kind of work has to stay possible. The rule instead, and it is already what machine.md § 3
practises: a module reads a compiler internal through a function, and the function is frozen.
val_reg/dst_reg/dst_done are the three the contract publishes by name; walk_ret_type() and
x86_argreg_at(slot) are the shape for the rest. A consumer that needs a global asks for an
accessor, the accessor lands as an additive MINOR, and it is in the inventory from that day.
Two things keep this honest without a new gate. First, the in-repo consumers are the gate: nine
modules under lib/ and examples/ read the globals of § 1.2c, and check-float, check-wide,
check-surface, check-kernel, check-avr and check-inert are red the moment one is renamed --
a64_out cannot be renamed quietly today. Second, the policy carries one operational sentence for
the out-of-tree consumer: a rename or removal of a src/ global goes in the release note, which
is what the memory rule (teko, 0.15.12) actually asked for and costs nothing to obey.
b. Diagnostic TEXT is not API; the exit code and the stream are. 373 documented messages, 19
asserted by a gate (§ 1.2d). Freezing 373 English sentences would make every improvement to a
message a MAJOR, and messages are the part of a compiler that most deserves improving. What a
program may rely on is what a program can act on: the exit code and whether the text went to
stderr. Both are already documented per family in diagnostics.md § The four shapes and § 11's
exit-code-2 section. The 19 that a gate asserts are protected by that gate -- changing one turns
make check red, which is exactly the "you will notice" a freeze needs -- so no new mechanism is
bought for them. The policy says: a message's wording is a PATCH; its exit code is a MAJOR.
c. The -opt road, the goldens and the object bytes are not the surface either. They are the
determinism promise (../determinism.md), they already have ten gates, and
they move by design whenever the compiler improves. A freeze on emitted bytes would forbid the
optimizer. Named here only because "freeze the formats" could be misread to include them: an object
file's LAYOUT is frozen by its own standard (Mach-O, ELF, COFF) and by check-obj against the
frozen seed; its CONTENT is not.
4. The mechanism -- decision (design question 2) #
4.1 One extractor #
scripts/surface-extract.sh prints the inventory to stdout, kind<TAB>name, bytewise-sorted
(LC_ALL=C sort). It is the ONLY definition of any of the seven lists. scripts/check-docs.sh is
rewritten to call it (surface-extract.sh sym, … flag, … toml, … dir) instead of carrying
four grep -hoE pipelines of its own -- it loses about 20 lines and its behaviour, its four ok
coverage: lines and its counts are unchanged except that sym goes 206 -> 209. That is the whole
answer to § 11.3: two regexes that must agree are a bug waiting; one regex cannot disagree with
itself.
4.2 The recorded file #
tests/golden/surface.txt -- under tests/golden/ and not docs/reference/ because it is a
recorded artefact compared on every run and rewritten only on purpose, which is that directory's
whole meaning, and because a reader looking for the surface should be sent to
docs/reference/hooks.md, not to a 420-line list. Format:
# mc public surface -- recorded by scripts/check-freeze.sh --record.
# One line per entry: <kind> <TAB> <name> [<TAB> deprecated <version> -> <replacement>]
# See docs/reference/hooks.md § 8 (Stability) before editing this file by hand. Do not.
bundle float
bundle mc/core_min
...
dir define
flag --exe
lock permissions
machine 5
sym val_reg
toml project.opt
Sorted by kind then name, bytewise, so a diff is readable and a merge conflict is local.
tests/golden/README.md gains a paragraph.
4.3 The gate #
scripts/check-freeze.sh (make check-freeze, inside make check). It re-extracts and compares:
| what it sees | verdict | what it prints |
|---|---|---|
| identical | ok | ok freeze: 420 entries (209 sym, 50 flag, 35 toml, 10 dir, 101 bundle, 14 lock, 1 machine) |
an entry gone, not marked deprecated | FAIL | the kind and name of each, then a removal is a MAJOR (docs/reference/hooks.md § 8). Deprecate it first, or re-record deliberately with scripts/check-freeze.sh --record. |
an entry gone, marked deprecated | FAIL until re-recorded | the same, minus the first sentence: the removal is legal, the file must be updated in this commit |
| an entry added | FAIL until re-recorded | new: sym foo -- additive, a MINOR. Re-record: scripts/check-freeze.sh --record |
machine moved down, or changed with no new version paragraph in machine.md | FAIL | the machine contract's version went 5 -> 4 / … -> 6 with no "Version 5 → 6" paragraph in docs/reference/machine.md |
Re-recording is a flag, --record, not the goldens' delete-and-rerun. The hash goldens are
rewritten by deleting the file because nobody reads their content; this file's content is the review
artefact -- the diff is the API change -- so the command that writes it should be nameable in a
commit message and the file should never be re-created from nothing by accident.
Every change to the surface therefore costs one extra committed line in the same pull request, which is the point: an addition is cheap and visible, a removal is impossible unless deliberate.
4.4 What the gate does not do #
It does not know what version it is (§ 0: the tree says 0.0.0-dev and a gate asserts it), so it
cannot check that a deprecation has aged one major. That check is the release:major label on the
pull request, which is a human decision autotag.yml already reads
(../ci.md § 2). The gate's job is narrower and complete: a removal cannot happen by
accident, and cannot happen at all without a diff a reviewer reads.
5. The policy -- decision (design question 3) #
New section docs/reference/hooks.md § 8, Stability, ~90 lines. One-line pointers to it from
cli.md, toml.md, directives.md, packages.md, bundle.md and machine.md, and a row in
docs/ci.md § Versioning's label table.
5.1 The rules #
| change | bump | example |
|---|---|---|
| a message's wording; a fix with no inventory line moved | PATCH | 0.15.37's registry-index fix |
a new entry of any kind; a new MTASK_* slot under the null-slot rule; a new accessor for a global | MINOR | M49's six slots, version 4 → 5 |
| an entry REMOVED; a signature changed; an exit code changed; behaviour a conforming module can observe changed; a machine-contract change that is not append-only | MAJOR | none yet |
Conforming carries weight: a module that reads a src/ global, asserts a message's wording, or
depends on an emitted byte is not conforming, and § 3 says so in the same page.
5.2 The deprecation lane #
From 1.0.0 there is no dry rename. To replace old with new:
- Add
new. It is a MINOR and it appears in the inventory. - Keep
oldas an alias -- for a symbol, a one-line wrapper that callsnew; for a flag, a TOML key, a directive or a bundle name, the existing parse site accepting both. - Mark the old entry in
tests/golden/surface.txt:sym<TAB>old_name<TAB>deprecated 1.1.0 -> new_name. The version is written by a human, because the tree cannot know its own (§ 0); the reviewer checks it against the tag the pull request will cut. - Remove
oldno earlier than the next MAJOR, in a pull request labelledrelease:major.
One major of overlap, exactly as the direction says. The deprecation marker is the only expressible removal, and § 4.3 is what makes that true mechanically.
5.3 The note, specified and priced, NOT built #
A deprecated name should say so. Decided:
- Where a name is parsed from text -- a CLI flag, a TOML key, a
#directive, a bundle name -- the compiler prints once, to stderr:mc: --old-flag is deprecated since 1.1.0, use --new-flag. Four sites (src/cli.mc's option loop,toml_get's callers via one helper,do_directive,lex_include_name), one shareddep_note(kind, old, since, new)with a seen-bit per entry so a build says it at most once per name. ~25 lines ofsrc/, and 0 globals (the table is a field of an existing record). - For a symbol, the compiler says nothing. There is no parse site: a symbol name is resolved by
the linker, and a wrapper that printed would put a message on the stderr of every taught
compiler that still calls it -- and a taught compiler's stderr is its own product, not mc's. The
deprecation for a symbol lives in the inventory, in
hooks.mdand in the release note.
M53 ships no deprecation, so it ships no note: the 25 lines land with the first one, and this
section is what that pull request implements. Writing them now would be 25 lines of src/ that no
test can exercise, which is 25 lines that rot.
5.4 What 0.x may still do, and when the promise starts #
| period | what the gate does | what a removal costs |
|---|---|---|
| 0.16.x (today) | check-freeze does not exist | nothing -- 0.x semver lets a minor break |
| 0.17.0, the RC | the gate lands, binding | the deprecation lane, § 5.2; but 0.17.x takes bug fixes only (§ 7), so nothing should be removed at all |
| 1.0.0 onward | unchanged | release:major, publicly promised |
The RC is where the freeze starts -- the gate, the inventory and the policy are all in 0.17.0 -- and 1.0.0 is where it is promised, which is the only difference between the two.
6. The canary -- decision (design question 4) #
Constraint, from the direction: no credential. mc cannot dispatch a workflow in teko's
repository (that needs a PAT) and teko cannot receive mc's release webhook (a foreign repository
does not get one). Both halves are therefore pull, and the only thing that crosses the boundary
is a public URL each side can read anonymously.
6.1 The protocol #
mc: publish ──► GitHub Release created as a PRE-RELEASE, all assets attached
│
teko: schedule (15 min) │ GET /repos/minicompiler/mc/releases (public, no token)
▼
newer pre-release than its pin? → run the whole recipe against the tarball
│
▼ writes with its OWN GITHUB_TOKEN, to its own repo
raw.githubusercontent.com/teko-org/teko-lang/canary/<version>.json
│
mc: promote ◄───────────┘ polls that URL every 60 s, up to 90 minutes
ok → gh release edit "$TAG" --prerelease=false
fail → job RED, the release stays a pre-release, nothing is unmade
timeout → advisory before 1.0.0, RED at 1.0.0
│
▼
mc: publish-to-registry needs: promote (a pre-release must not become the newest row)
6.2 The five decisions inside it #
- Every release is born a pre-release while the canary is armed.
release.yml'spublishjob passes--prereleasewhenevervars.MC_CANARY == 'true', not only for a--suffixed version (:649'scasestays, OR'd with the variable). The assets, the checksums and the body are identical; the flag is the only difference, and it is the one thing GitHub lets a later job flip. - The verdict is a file at the ROOT of a branch named
canaryin teko's repository,<version>.json— notcanary/<version>.json, which would put a secondcanaryin the path, since the branch already occupies the ref position. Read athttps://raw.githubusercontent.com/<owner>/<repo>/canary/<version>.json, which for the consumer registered on 2026-09-14 isteko-org/teko-lang:{"version":"0.17.0","status":"ok","run":"<actions url>","utc":"…"}, with the version bare (nov). A file and not a commit status, because a status is about a teko commit and the canary is about an mc version, and the mapping would have to be invented; a branch and not GitHub Pages, because it needs no Pages setup and is versioned by git. teko writes it with its ownGITHUB_TOKEN; mc reads it withcurland no token at all. promoteis a job, not a step, forpublish-to-registry's exact reason (release.yml:672): a failed promotion must not be able to unmake a published release, and a rerun of the job alone is the retry. Manual promotion is one command,gh release edit v0.17.0 --prerelease=false, and the release note says so.- Timeout 90 minutes (
timeout-minutes: 95), polling every 60 s. The budget: up to 15 minutes for teko's schedule to notice, plus its recipe, which on its own 2026-09-14 dry run was abuild, 64 fixtures, a bootstrap fixed point,check-docsandmc limits. Aworkflow_dispatchon teko's side removes the 15. - "Bloqueante no 1.0.0" is one repository variable. Before 1.0.0 the
promotejob iscontinue-on-error: trueand a timeout is a::notice::with the release promoted anyway -- advisory, so a teko outage cannot hold an mc patch. At 1.0.0vars.MC_CANARY_REQUIREDis set,continue-on-errorbecomes false and a timeout leaves the release a pre-release. Same shape asMC_REGISTRY_PUBLISH(§ 0), same reason: a job that is red by design would hide a real failure.
6.3 What the mc side owes and what it does not #
It owes: the pre-release flag, the poll, the flip, needs: promote on the registry job, and a
docs/ci.md § The canary that states the contract precisely enough for teko to implement its half
without asking -- the URL shape, the JSON keys, the timeout, and that a missing file is neither
ok nor fail. It does not owe teko's workflow, which is teko's pull request, and it does not
owe a fallback: if teko never publishes a verdict, every release before 1.0.0 is promoted on the
timeout and the pipeline is exactly what it is today.
7. RC 0.17.0 and the version string -- decision (design question 5) #
0.17.0 is an ordinary minor tag. "RC" is a STATE of the repository, not a suffix on a version.
Three reasons, all measured in § 1.3:
scripts/next-version.sh:206and.github/workflows/tag.yml:52-56refuse a suffix, each with its own error text, anddocs/ci.md§ Versioning records that as a decision with a reason. Relaxing two refusals for one tag is a change to the release automation with no second user.- A
--suffixed version is skipped by the compiler's own resolver (pkg_newest/pkg_pickwithpre = 0), somc upgradeandmc pkg add mcwould not offer it. A release candidate nobody can install is not one. - The candidate semantics already have a home where they can be acted on: the GitHub release's pre-release flag, which § 6 flips on a verdict. That flag is orthogonal to the version string, which is what lets 0.17.0 be an ordinary version and still be a candidate.
So:
| 0.17.0 | an ordinary release:minor tag, cut by autotag.yml as any other. It contains the freeze gate, the inventory, the policy and the canary in advisory mode |
| 0.17.x | bug fixes only. Nothing is added to the inventory and nothing is removed from it; git log --stat -- tests/golden/surface.txt between the two tags should be empty |
| 1.0.0 | a release:major tag. The only difference in the tree is vars.MC_CANARY_REQUIRED and the policy page's "promised" wording |
scripts/set-version.sh and scripts/check-bundle.sh's sentinel are untouched: they already do the
right thing for a plain X.Y.Z, and this decision means no suffix ever reaches them.
8. Acceptance (runnable; numbers, not adjectives) #
- The inventory:
scripts/surface-extract.sh | wc -lis 420, and per kind 209 sym, 50 flag, 35 toml, 10 dir, 101 bundle, 14 lock, 1 machine.tests/golden/surface.txtis that output byte for byte;check-freezeprints theok freeze:line with those numbers. - The hole is closed:
grep -x 'sym val_reg' tests/golden/surface.txtsucceeds, and so dodst_reganddst_done.check-docsis green at 209 symbols with no doc edit (they are already written as calls inmachine.mdandhooks.md). - One definition:
grep -c 'grep -hoE' scripts/check-docs.shis 0 -- all four pipelines moved intosurface-extract.sh, andcheck-docs's fourok coverage:lines are otherwise unchanged. - The gate has teeth, proved in both directions and in the commit:
(a) renaming
p_typetop_typinsrc/parse.mcmakescheck-freezeFAIL namingsym p_typeand printing the MAJOR sentence; (b) addingvoid syntax_probe(uptr f)makes it FAIL asnew: sym syntax_probe -- additive, a MINOR; (c) markingp_typedeprecated and then removing it FAILS until--recordis run, and the recorded diff is one deleted line; (d) editingmachine.md's version line to 4 FAILS; to 6 with no new paragraph FAILS; to 6 with one passes after--record. src/untouched:git diff --stat src/ stage0/is empty,check-inertis identical on both roads for the 33 objects and all five taught examples, and not one of the ten goldens moves.check-obj32/32 against the frozen seed,check-limits17/17 under 90% withglobalsunchanged.- The canary round trip, measured once in advisory mode against a real teko run: mc cuts a
throwaway tag, the release is created as a pre-release, teko's workflow writes
canary/<version>.jsonwith"status":"ok", mc'spromotereads it and flips the flag. The elapsed time frompublishfinishing topromoteflipping is recorded in this spec's § Implementation notes, and it is under the 90-minute timeout. - A
failverdict leaves the release a pre-release andpromotered, with every asset still attached andgh release edit --prerelease=falsestill available -- asserted once, by writing a"status":"fail"file by hand before a throwaway tag. publish-to-registryruns afterpromote, so a pre-release is never announced: read from the workflow'sneeds:and confirmed by the job graph of the throwaway run.make checkgreen end to end withcheck-freezein it, andmake check-linux-hostRC 0 over all four cells -- both unchanged in every other count, because nothing the compiler emits moved.- Docs with
check-docsgreen:hooks.md§ 8 (new), the six one-line pointers,ci.md§ Versioning's label table and § The canary (new),tests/golden/README.md,docs/plan.md's M53 row,CLAUDE.md§ State; every ```mc fence compiles;site/check-sitegreen.
9. Steps -- gated commits #
| order | step | content | lines (est.) | gate |
|---|---|---|---|---|
| 1 | A the extractor and the gate | scripts/surface-extract.sh (the seven kinds, the three exact names of § 1.2a, the lock grep); check-docs.sh rewritten to call it; scripts/check-freeze.sh; tests/golden/surface.txt recorded; Makefile; tests/golden/README.md | LANDED: surface-extract.sh 81, check-freeze.sh 125, check-docs.sh +19/−13, Makefile +17/−4, surface.txt 420 entries + 3 header lines | § 8.1-8.5 |
| 2 | B the policy ✔ LANDED | hooks.md § 8; the six pointers; ci.md § Versioning's label table; machine.md's pointer to § 8 | LANDED: hooks.md § 8 +108/-5 (110 lines in place); one-line-or-two pointers in cli.md +3, toml.md +3, packages.md +5, bundle.md +4, objects.md +5, machine.md +5; ci.md § Versioning +18/-6 | § 8.10, check-docs green |
| 3 | C the canary ✔ LANDED | release.yml: the MC_CANARY pre-release flag (+ the release note's Canary paragraph), the promote job, needs: [publish, promote] on publish-to-registry; scripts/canary-poll.sh (new, 167 lines, 106 of them code, --test and --url); ci.md § The canary | release.yml +97/-8, scripts/ +167 new, docs/ci.md +158/-3 | § 8.6-8.8 -- actionlint clean, the four outcomes reasoned from the lines, the poll script run locally in DRY_RUN mode; the first real round trip is the next tag with MC_CANARY armed |
| -- | D the note | § 5.3's dep_note, four sites | ~25 in src/ | deferred: it lands with the first deprecation |
Each step is make check green on its own, and none of them rewrites a golden -- the first
milestone since M41 for which that is true, because src/ is not edited until step D, which is not
in M53.
10. Files and estimated deltas #
| file | lines | what |
|---|---|---|
scripts/surface-extract.sh | ~70 | new: the single definition of all seven kinds; surface-extract.sh [KIND] prints one kind, no argument prints all |
scripts/check-freeze.sh | ~90 | new: compare, classify, --record |
scripts/check-docs.sh | +12 / −20 | four pipelines become four calls; sym 206 → 209 |
tests/golden/surface.txt | 420 | new, recorded |
tests/golden/README.md | +18 | what this file is, why --record and not delete-and-rerun |
Makefile | +4 | check-freeze in check, in .PHONY, and in the Linux and Windows subsets (it needs no compiler -- it is grep over src/ -- so it runs on every host) |
.github/workflows/release.yml | +45 | the MC_CANARY flag on publish; the promote job; needs: promote |
docs/reference/hooks.md | +90 | § 8 Stability |
docs/ci.md | +75 | § Versioning's API column; § The canary |
docs/reference/{cli,toml,directives,packages,bundle,machine}.md | +6 | one pointer each |
docs/plan.md, CLAUDE.md | +2 | the row, the § State entry |
src/, stage0/, the ten goldens | 0 | § 8.5 |
Net new src/ code: 0. That number is the milestone's argument: a freeze that had to change the
compiler to describe the compiler would be describing the wrong thing.
11. Risks #
- Freezing a name teko needs renamed. Measured at low: teko registers
syntax_expr,machineandtarget(§ 0), the owner stated on 2026-09-08 that the hooks API does not move again, and teko replied the same day that nothing pends. The escape is § 5.2's lane, which costs one MINOR and one alias and is exactly what the direction asks for. The residual is that a bad name gets one major of life -- which is the price of the promise and not a defect of it. - The canary's latency. Bounded at 90 minutes (§ 6.2.4) and advisory until 1.0.0, so the worst
case before 1.0.0 is a release that takes an hour and a half to leave pre-release. After 1.0.0
the worst case is a release that stays a pre-release until a person promotes it by hand, which
is one command. A
workflow_dispatchon teko's side is the fast path and should be documented in the release checklist. - The inventory drifting with
check-docs's regex. Closed by construction in § 4.1: there is one regex, in one file, read by both gates. The residual is that the regex itself is a choice about what is public (17 prefixes and now 8 exact names), and widening it later adds entries -- an additive, re-recorded MINOR, which is the right cost for "we noticed something was public all along". § 1.2a is that event happening once, deliberately, before the freeze rather than after it. - A public symbol outside the prefixes stays invisible. The regex is a heuristic and always
was. What makes this survivable is that the ONLY names a consumer may rely on are the ones
docs/reference/documents, andcheck-docsrequires a documented name to exist -- so a documented-and-frozen name cannot vanish, and an undocumented one was never promised. § 1.2a found the one case where those two sets had parted (three names documented as frozen and unextracted), and the fix makes the invariant "documented ⊆ inventory" true for the families the regex covers. A sweep ofdocs/reference/for call-shaped names outside the inventory is priced at ~20 lines of one-offgrepand is worth running once in step A. - A merge conflict in a 420-line sorted file. Sorted by kind then name bytewise (§ 4.2), so two
pull requests adding different symbols conflict only if the names are adjacent. Cheaper than any
alternative and identical to what
mc.toml's[package].filesalready does. --recordused as a reflex. The gate's message is written to discourage it (§ 4.3 prints the policy sentence before the command), and the recorded diff is in the pull request where a reviewer sees a deleted line. There is no technical defence against a determined re-record, and there should not be: the goldens have the same property and it has held for fifty milestones.- 0.17.x taking a feature. The gate cannot stop it -- an addition is a legal MINOR at any
point. § 7's "bug fixes only" is a discipline, and the check is one command in the 1.0.0
checklist:
git log --stat v0.17.0..HEAD -- tests/golden/surface.txtmust be empty. - The verdict file's owner. mc reads a URL in a repository it does not control. A compromised
or mistaken teko could promote a bad mc release -- but only to
prerelease: false, which is a visibility flag; nothing about the artefacts changes andpublish-to-registrystill runs its own sandboxed validation. The blast radius is "a release becomes visible early", which is today's behaviour for every release. - Diagnostics left unfrozen (§ 3b). A consumer that greps a message will break on a PATCH. The
answer is written down in the policy rather than engineered around: exit codes are the contract,
and
mchas 373 messages precisely because it improves them.
12. Decisions #
| decision | why | |
|---|---|---|
| D1 | The surface is seven kinds, each extracted from one source of truth in the tree, never written by hand: sym flag toml dir bundle lock machine | it is the rule check-docs.sh already states for four of them; a hand-written list is a second source of truth and would be wrong within a month |
| D2 | val_reg, dst_reg and dst_done join the extraction as exact names, 206 → 209 | they are published BY NAME as frozen in machine.md § 3 and matched no prefix -- a found defect, fixed not voted (§ 1.2a) |
| D3 | bundle covers the <mc/*> parts and the libraries in one kind, from tools/bundle.list column 1 | a part is named exactly as a library is ([compiler].core = "<mc/core_min>"); M52 moved 41 of the 101 out of the blob without changing one name, so the name is the surface and the road is not |
| D4 | lock is a seventh kind, 14 keys from tm_cat(key, "…") | the direction names mc.lock; a computed TOML path is invisible to check-docs's regex, so the format was covered by nothing |
| D5 | machine is ONE line carrying the contract's version, not 37 slot names | the contract's promise is about order and signatures, which a name list cannot express; machine.md already versions itself append-only with a written changelog |
| D6 | src/ globals are NOT API. The migration is an accessor per global a consumer needs, which is then frozen as a sym | 448 of them; freezing the set would have made M48 C0's globals diet a MAJOR, and that diet is what keeps check-limits under the frozen seed's ceiling. machine.md § 3's three published names are the precedent for the exception |
| D7 | A rename or removal of a src/ global goes in the RELEASE NOTE; no gate is added for it | the in-repo modules under lib/ and examples/ already fail make check on such a rename; the out-of-tree consumer needs an announcement, which is what the memory rule asked for and costs nothing |
| D8 | Diagnostic TEXT is not API; the exit code and the stream are. Wording is a PATCH, an exit code is a MAJOR | 373 documented messages against 19 a gate asserts; freezing English sentences would make improving a message a MAJOR, and messages are what most deserves improving. The 19 are protected by the gate that asserts them |
| D9 | The inventory lives in tests/golden/surface.txt and is re-recorded with --record, not by deleting the file | it is a recorded artefact compared every run -- the goldens' meaning exactly -- but unlike a hash its CONTENT is the review artefact, so re-creating it from nothing must be a named act |
| D10 | check-freeze fails on an addition too, until the file is re-recorded | the goldens' discipline: every surface change costs one committed line in the same pull request, which is what makes the diff the announcement |
| D11 | One extractor read by both gates; check-docs.sh loses its four pipelines | two regexes that must agree are a bug waiting (§ 11.3); one cannot disagree with itself |
| D12 | Deprecation = add new + keep the alias + mark the old entry with the version a human writes; remove no earlier than the next MAJOR | the direction, verbatim. The version is written by hand because check-bundle.sh guarantees the tree cannot know its own (§ 0) |
| D13 | The deprecation note is printed only where a name is parsed from text (flag, TOML key, directive, bundle name), never for a symbol; and it is SPECIFIED AND PRICED, not built | a symbol has no parse site, and a wrapper that printed would write on a taught compiler's own stderr. M53 ships no deprecation, so 25 lines that no test can exercise would only rot (§ 5.3) |
| D14 | Every release is born a GitHub pre-release while MC_CANARY is armed; a promote job flips the flag on a public verdict | the flag is the one property of a published release a later job can change, and it is already implemented (release.yml:649) |
| D15 | The verdict is a JSON file in a canary branch of teko's repository, read anonymously over raw.githubusercontent.com | the direction's "sem credencial", both ways: teko writes with its own token, mc reads with none. A commit status would be about a teko commit, not an mc version |
| D16 | promote is a job with needs: publish, and publish-to-registry gets needs: promote | publish-to-registry's own precedent -- a failed follow-up must not unmake a release -- and a pre-release must not become the registry's newest row |
| D17 | Advisory before 1.0.0 (continue-on-error: true, a timeout promotes), required at 1.0.0 (vars.MC_CANARY_REQUIRED) | "bloqueante no 1.0.0", literally; and a teko outage must not be able to hold an mc patch before then. Same gating shape as MC_REGISTRY_PUBLISH |
| D18 | 0.17.0 is an ordinary minor; "RC" is a STATE of the repository, not a version suffix. No 1.0.0-rc.N tag | two places refuse a suffix on purpose and docs/ci.md records why; and a pre-release VERSION is skipped by mc upgrade and mc pkg, so nobody could install the candidate (§ 1.3). The candidate semantics live in the release's pre-release flag instead |
| D19 | 0.17.x takes bug fixes only; the check is git log --stat v0.17.0..HEAD -- tests/golden/surface.txt being empty | the RC's whole meaning, and it is checkable in one command rather than asserted |
| D20 | src/ is untouched and no golden moves | a freeze that had to change the compiler to describe the compiler would be describing the wrong thing |
13. The 1.0.0 gate #
The direction: "A 1.0.0 sai quando a lista de lacunas da teko chega a zero — ela auto-hospeda nas 5 pernas e publica a teko_std sem pedir gancho novo ao mc." That list is teko's to keep, and on 2026-09-08 23:40 it was already empty (§ 0). What this side owes at the tag:
| here, before 1.0.0 | M53 steps A, B and C merged and shipped in 0.17.0; 0.17.x carrying bug fixes only (D19, checked by the one command); the canary's round trip measured green at least once in advisory mode (§ 8.6) |
| here, at the tag | release:major; vars.MC_CANARY_REQUIRED set; hooks.md § 8's wording moves from "from 0.17.0 this gate is binding" to "promised"; the release note names the seven kinds and their counts |
| teko, before the tag | its own gap list at zero, restated here; its canary branch live and its workflow scheduled; a green verdict for the 0.17.0 line |
| registry, before the tag | MC_VERSION raised, as at every cut (M52.md § 15) |
Nothing on this side is waiting for teko: steps A-C land on their own schedule and 0.17.0 can be cut without a single teko change, because the canary is advisory until the major. The gate is on 1.0.0 alone, which is where the promise is.
Implementation notes -- step C (the canary) #
Written while landing § 6, § 8.6-8.8 and § 9 row 3. Every deviation from the text above is here.
- The verdict lives at the ROOT of the
canarybranch, and § 6.2 point 2 has been corrected in place. The prose said "a file in a branch of teko's repository,canary/<version>.json" while the URL beside it readraw.githubusercontent.com/<owner>/teko/canary/<version>.json. Those are two different files: the raw URL is<owner>/<repo>/<REF>/<path>, so the branch already occupies thecanarysegment and a file inside acanary/directory would be.../teko-lang/canary/canary/0.17.0.json. The URL was right and the prose was wrong. The ROOT form is also what the consumer was told through the channel on 2026-09-14 and what it is building, so the spec's prose is now the URL's shape and § 6.1's diagram names the real repository.
- The consumer is
teko-org/teko-lang, read out of the channel file (/Users/schivei/mc-teko/teko-to-mc.md, 2026-09-14) and confirmed againstgh repo list teko-org-- not guessed from the milestone's shorthand. It is the DEFAULT ofCANARY_REPOin the script and not a literal in the workflow, withvars.MC_CANARY_REPOoverriding it, so a move is one variable and no commit. Itscanarybranch does not exist yet (gh api repos/teko-org/teko-lang/branches/canary-> 404, and the raw URL answers 404), which is exactly the state the poll treats as "not written yet".
- Armed is
vars.MC_CANARY == 'true'(§ 6.2 point 1's own spelling), not"1"or"required";"required"is a second variable,MC_CANARY_REQUIRED, because the two questions are independent -- a release can be born a pre-release and promoted advisorily long before the wait blocks. Same gating shape and same reason asMC_REGISTRY_PUBLISH(§ 0).
- The timeout promotes before 1.0.0, per § 6.2 point 5 read literally: "a timeout is a
::notice::with the release promoted anyway -- advisory". It is a::notice::and not the::warning::the task's brief supposed; the spec is what the script follows. At 1.0.0 (MC_CANARY_REQUIRED = true) the same timeout is an::error::and the release stays a pre-release.
continue-on-erroris an expression,${{ vars.MC_CANARY_REQUIRED != 'true' }}-- so the two states of § 6.2 point 5 are one variable and not two workflow edits. AfailVERDICT is exit 1 either way:continue-on-errorkeeps the RUN green before 1.0.0, but the job renders red, which is what § 8.7 asks of it.
- The poll is a script and not thirty lines of YAML.
scripts/canary-poll.sh(167 lines, 106 of them neither comment nor blank) can be run off a runner, which is the only way § 8.6's round trip can be reasoned about before a real tag exists, and it is the repository's own precedent (next-version.sh --test,libroot.sh,release-assets.sh).--testis 7 assertions overfile://URLs, no network and no framework:okpromotes, av-prefixed version field is accepted,failexits 1 and says so, a verdict naming another version keeps waiting, an advisory timeout promotes with the::notice::, a required timeout is exit 1 with the::error::, and the derived URL is the one § 6.2 point 2 writes.--url TAGprints the URL without touching the network, which is how that last case avoids a live request.DRY_RUNprints thegh release editit would run instead of running it.
- The registry job's
ifhad to name a status function.needs: [publish, promote]alone would skippublish-to-registryon every release whileMC_CANARYis unset, because a job whoseneedsis SKIPPED is skipped too. It is!cancelled() && vars.MC_REGISTRY_PUBLISH == 'true' && needs.publish.result == 'success' && needs.promote.result != 'failure'. The load-bearing half is unambiguous: at 1.0.0continue-on-erroris false, afailor a required timeout makesneeds.promote.resultequalfailure, and the pre-release is not announced. Before 1.0.0 the advisory job'sresultis reported assuccessand the registry runs -- which is what "advisory" means, and it is documented rather than relied on silently.
- The release note says how to promote by hand, and only while the canary is armed:
publish's body composer appends a Canary paragraph withgh release edit <tag> --prerelease=falsewhenvars.MC_CANARYistrue. Whoever finds a release still marked pre-release has the one command in front of them without readingdocs/ci.md.
- Unarmed is byte for byte the pipeline that was there.
publish'scase "$VERSION" in *-*)is untouched and the newif [ "$CANARY" = "true" ]below it is the only addition;promoteis skipped; the registry job's extra conditions are all true. Nothing insrc/,stage0/,tests/golden/orlib/was touched --git diff --stat src/ stage0/ tests/golden/is empty, no golden moves, andmcemits not one different byte.
actionlintis clean. The two findings it reports onrelease.yml(SC2155 at:527, SC2086 at:547) are byte for byte the ones it reports on the same file frommain, in the same steps, and predate this change;shellcheck -s sh scripts/canary-poll.shis clean.
- The four outcomes, from the lines.
ok->promote()->gh release edit "$TAG" --prerelease=false, exit 0, registry runs.fail->::error::, the by-hand line, exit 1; advisory the run is green and the registry still runs, required the run is red and the registry is skipped.timeout-> advisory:::notice::+promote()+ exit 0; required:::error::+ exit 1, the release left a pre-release.unarmed->promoteskipped, the release created exactly as before, the registry gated onMC_REGISTRY_PUBLISHalone as it always was.
- What is NOT proved here and when it will be. § 8.6 asks for the round trip measured once
against a real teko run and § 8.8 for the job graph of a throwaway tag. Neither can be done from
a pull request:
release.ymlfires on av*tag, the owner cuts tags, and teko'scanarybranch does not exist yet. The first real round trip is the next tag withMC_CANARYarmed -- the elapsed time frompublishfinishing topromoteflipping goes into these notes then, with the run URL.
The first round trip, measured (v0.16.1, 2026-09-14). MC_CANARY=true was armed at 15:42
after the consumer reported its poller live (its 0.16.0.json said fail -- windows/x86_64, and
it was true). publish finished 16:23:47; promote polled the raw URL until 17:54:12 -- the
90-minute timeout -- and, advisory, promoted the release (::notice::, job green,
prerelease: false, 20 assets, publish-to-registry ran after it). No verdict existed then:
the consumer's mc-canary.yml runs on schedule (*/15 * * * *) and GitHub enqueued zero
scheduled runs in the first two hours after the workflow was merged (a known behaviour of a
freshly added, frequent cron). A manual workflow_dispatch wrote 0.16.1.json
{"status":"ok","run":".../runs/34877983682","utc":"2026-09-14T18:02:21Z"} eight minutes later
-- the whole recipe green on 0.16.1, the windows/x86_64 fixpoint included. So: the mc half works
end to end (armed, waited, timed out, promoted, published), the consumer half needs its cron to
fire before MC_CANARY_REQUIRED can be set; until GitHub's scheduler is seen enqueuing it, the
consumer dispatches by hand and the 90-minute budget is what absorbs that. Risk 3 (latency) is
therefore the live one, and it is the consumer's to close, without a cross-repository token.
The second round trip (v0.17.0, 2026-09-14) closed in four minutes: publish 19:49:41, the
consumer's manual dispatch wrote 0.17.0.json ok at 19:53:28, promote flipped the flag at
19:53:49, publish-to-registry ran after it. One CDN fact came out of it, reported by the
consumer: raw.githubusercontent.com had answered 404 for minutes AFTER the push -- a negative
cache from a poll made before the file existed -- while the contents API already served it.
scripts/canary-poll.sh now busts that cache with a query on the raw URL and, when raw still
answers nothing, asks api.github.com/repos/<repo>/contents/<version>.json?ref=<branch> (public;
the job's token only lifts the unauthenticated 60/h ceiling that ninety one-minute polls would
hit). The self-test's file:// fixtures still pass 7/7.
Implementation notes -- step A #
Written after the step landed. Everything here was measured on this tree.
- The counts are the spec's, to the line.
sh scripts/surface-extract.sh | wc -lis 420: 209 sym, 50 flag, 35 toml, 10 dir, 101 bundle, 14 lock, 1 machine, andtests/golden/surface.txtis that output with three#header lines above it.check-freezeprintsok freeze: 420 entries (209 sym, 50 flag, 35 toml, 10 dir, 101 bundle, 14 lock, 1 machine).
- The hole of § 1.2a is closed and it cost no documentation.
val_reg,dst_reganddst_donejoin the extraction as exact names (206 -> 209) andcheck-docsis green with no edit todocs/reference/, because all three are already written as calls inmachine.mdandhooks.md-- which is what § 1.2a predicted.grep -x 'sym val_reg' tests/golden/surface.txtsucceeds, and so do the other two.
- One extractor, and
check-docs.shhas no regex left.grep -c 'grep -hoE' scripts/check-docs.shis 0: its four pipelines became foursh scripts/surface-extract.sh <kind>calls (+19/−13, most of the added lines being the comment that says where the families now live). Its fourok coverage:lines are unchanged except the first: 206 -> 209 symbols, 50 flags, 35 TOML keys, 10 directives; samples 52 and links 543 are untouched.
lockis keyed on the variable, not on the shape. The 14 keys come fromtm_cat(key, "...")withkeyspelled literally -- every one of the 14 call sites insrc/pkg.mc,src/tool.mcandsrc/deps.mcnames that variable. A regex over anytm_cat(<ident>, "<word>")picks uptm_cat(dir, "x")andtm_cat(ldst, "x")as a fifteenth keyxand missessha256unless digits are allowed -- both measured before the narrower form was chosen.
machinegets its own verdict and is excluded from the add/remove diff. A version bump is neither a removal nor an addition: it is one line whose value moved, and the rule is about the direction. Leaving it in the generic diff would have printedremoved: machine 5under the MAJOR sentence for an append-only bump, which is false. All four cases of § 4.3's table were measured:-> 4FAILS withthe machine contract's version went 5 -> 4;-> 6with no paragraph FAILS naming theVersion 5 → 6paragraph it wants;-> 6WITH such a paragraph FAILS asmachine: the contract version went 5 -> 6 / re-record; and after--recordit passes. The paragraph is matched asVersion <old> (->|→|-->) <new>, which is the shapemachine.md's own four changelog entries use (they spell it with the UTF-8 arrow).
- The deprecation marker is implemented, and nothing is deprecated. A removed entry whose
golden line carries a third column
deprecated <version> -> <replacement>fails with the shorter message (the removal is deprecated and legal; re-record it in this commit) instead of the MAJOR sentence -- measured by markingbundle floatand dropping itstools/bundle.listrow. § 5.3's compiler-side NOTE (D13) is a different thing and is not built.
- The policy pointer is to this spec, not to
hooks.md§ 8. That section is step B's, socheck-freeze's messages,tests/golden/README.mdand the new two-linehooks.md§ 8 all point at § 5 here and say where the full text will be. Step B replaces the string in one place (policyinscripts/check-freeze.sh).
- Teeth, measured in both directions, each with the tree restored afterwards:
renaming
p_typetop_typinsrc/parse.mcgivesremoved: sym p_type+ the MAJOR sentence ANDnew: sym p_typ -- additive, a MINOR, exit 1; deletingtools/bundle.list'sfloatrow givesremoved: bundle float+ the MAJOR sentence, exit 1; the pristine tree isok freeze: 420 entries, exit 0.
check-freezeruns on every host and is in all threecheck:lists -- it isgrepoversrc/,tools/bundle.listand one line ofmachine.md, and needs no compiler. On macOS it is placed right aftercheck-docs, the other gate that reads the same extractor.
src/andstage0/are untouched and no golden moved:git diff --stat src/ stage0/ tests/golden/*.sha256is empty, and the only new file undertests/golden/issurface.txtitself.
Implementation notes -- step B #
Written after the step landed. Docs only -- git diff --stat src/ stage0/ tests/golden/ is
empty, exactly as step A left it, because this step writes no code and freezes no new name.
hooks.md§ 8 came in at 110 lines, not ~90. The four blocks § 5 asks for (what the surface is, what is not, the rules table, the deprecation lane) fit the estimate; two more things earned their own subsections because leaving them as prose inside another one would have buried the one fact a reader comes here for: the note is specified and priced, not built (§ 5.3/D13, its own heading with the exact stderr line and the four parse sites it would need), and the RC/1.0.0 table (§ 5.4), which is what a reviewer actually checks against when a label is picked. Both are short, and both are load-bearing enough that a pointer alone (as the six other pages get) would have made this page say less than the spec asked it to.check-freeze.sh'spolicystring still names the spec, unchanged, on purpose. § 4.3 of the design and the header comment ofcheck-freeze.sh(M53 step A note 7) both said the string would move tohooks.md § 8once this section existed. It stays pointing atdocs/specs/M53.md § 5instead: the SPEC is the decision record with the "why" (§ 3's two arguments, the risks, the twenty decisions), andhooks.md§ 8 is the policy STATED for a reader who already accepts it -- a gate's failure message is read by someone about to make a change, and the spec is where the reasoning that change has to survive actually lives. Moving the string would have made the message shorter and the reader's next step longer.hooks.md§ 8 opens by pointing attests/golden/README.md, which is itself a pointer back to this section, so a reader following either road reaches the same page; nothing in step A's acceptance (§ 8.1-8.5) named the string's exact target, only thatcheck-docsstay green, which it does.- The six pointers are
cli.md,toml.md,packages.md,bundle.md,objects.mdandmachine.md--objects.mdin place of thedirectives.md§ 5's own prose names.directives.mddocuments the ten entries of thedirkind, which is the smallest of the seven and already fully enumerated on its own first page (#includethrough#embed, ten names, one line each) with nothing further to say about which of them may move.objects.mddocuments thegen_*/backend_*/machine_*API -- the single largest concentration of thesymkind -- and it is the one page on this list that ALREADY drew the byte/name distinction this section needs (docs/determinism.md's promise is about emitted bytes, which move by design; § 8's promise is about names, which do not) without a line added for it: the pointer there states the difference explicitly (M53 § 3c) rather than assuming a reader carries it over fromhooks.md. A pointer was not added todirectives.md: its own page needs no correction and none of its ten rows has ever been ambiguous about whether it was public. packages.md's pointer sits at § 4, not at the top of the page, beside themc.locksample thelockkind actually describes -- the top of the page is § 1's trust paragraph, and a stability pointer there would answer a question nobody asked yet.bundle.md's sits right after the intro paragraph and before the worked#include <sys>sample, which is where a reader first meets the word<mc/*>and can be told, in the same breath, that the name is frozen and the road that answers it (blob or tree, M52) is not.docs/ci.md§ Versioning gained a fourth column on the label table and one paragraph, not a new section. The task's own two asks -- "what the policy allows" per label, and "RC 0.17.0 is a state, not a suffix" -- were both already ANSWERED elsewhere on this page before this step: the canary section (step C, landed first) already explains the pre-release-flag mechanism in full, and the paragraph right above the label table already argued the state-vs-suffix point in general terms (docs/specs/M53.md§ 7). What was missing was the label table SAYING it, in the one place a human actually clicks a checkbox, and the milestone number named plainly rather than left to a cross-reference -- both closed in four lines, with the general argument left where it was and not duplicated.- No golden moves,
check-docsreports the seven counts unchanged, andcheck-freezereports the same 420 entries. Measured:sh scripts/check-docs.sh->ok coverage: 209 symbols, 50 flags, 35 TOML keys, 10 directives, samples and links counted separately and unaffected by a change with no fenced```mcblock in it;sh scripts/check-freeze.sh->ok freeze: 420 entries (209 sym, 50 flag, 35 toml, 10 dir, 101 bundle, 14 lock, 1 machine), byte for byte what step A recorded -- a policy page names no symbolsurface-extract.sh's regex could see, and aContract version 5line untouched by a pointer sentence placed above the blockquote that carries it. make check-docsgreen after every edit, checked per file rather than once at the end, so a broken relative link would be caught at its own source: every one of the six new inbound pointers tohooks.mdresolves, along with the two self-references insidehooks.md§ 8 -- one todocs/ci.md§ The baked version, one totests/golden/README.md-- and the link count incheck-docs's own report moves by exactly that many. One escape, found while writing this very paragraph:check-docs's link scan (grep -oE '\]\([^)]+\)') reads the whole tree, not markdown structure, so it cannot tell a real link from one merely QUOTED to illustrate its shape -- naming the pointer's markup literally, bracket and parenthesis together, made the gate try to resolve a file relative todocs/specs/where it does not live, and fail asunresolved links. Fixed by describing the pointer in prose, with no live bracket-paren pair next to it.
Implementation notes -- the first freeze regression (#92) and the arity column #
Written after the fix landed. The gate this milestone built caught nothing here, and the reason is worth the paragraph.
- What broke. PR #92 ("unsigned comparisons", machine contract v6) needed to ask the
comparison table for its unsigned half, and gave the EXISTING function the parameter:
cmp_cond(op)becamecmp_cond(op, uns). The consumer'steko_typeof.tk:318isif (cmp_cond(op) >= 0) return TY_I64;-- one argument -- so every taught compiler built on 0.17.3/0.17.4 died withwrong number of argumentsbefore it compiled a line of its own language. The canary held both as pre-releases; this is the first regression the canary caught that the gates did not, which is exactly the division of labour § 6 describes. - Two holes, not one.
cmp_condwas not insurface.txtat all -- thesymregex reaches a name through one of 17 prefixes or an exact-name list, andcmp_is neither -- and the inventory recorded NAMES, so even a listed name could have grown a parameter silently. Both are closed here:cmp_condandcmp_cond_ofjoin the exact-name list (211 syms, 423 entries), and asymline is nowsym<TAB>name<TAB>arity, the parameter count read from the definition's own(...)insrc/*.mc(0 for(), otherwise commas + 1). - The arity is a THIRD verdict, beside removed and added, and the only one whose remedy is
not "re-record":
changed: sym cmp_cond 1->2fails with a public function's parameter list is frozen: add a new name instead. Measured both ways -- with #92's two-parametercmp_condthe gate prints exactly that line and exits 1; with the restored one it printsok freeze: 423 entries. - The fix is the deprecation lane's step 1 and 2 without step 3: the new behaviour got a new
name,
cmp_cond_of(op, uns), andcmp_cond(op)is a one-line wrapper over it (cmp_cond_of(op, 0)) -- not a deprecated alias but the function it always was, so no marker and no version are written. Nothing about v6 changed:gen_binarystill askscmp_cond_of(op, cmp_unsigned(...))andres_binarystill askscmp_cond(op), which is the signed half by definition. - The consumer's shape is a gate now, not a report:
lib/user_cmpcond.mcisteko_typeof.tk:318transliterated, andscripts/check-surface.shbuilds a taught compiler from it and RUNS its pass, which asserts the signed condition, the -1 for a non-comparison and the unsigned twin under the new name. Againstorigin/main's compiler the same file islib/user_cmpcond.mc:16: wrong number of arguments, exit 1. - The arity column changed no other line of the inventory. The 209 pre-existing syms all
carry one arity apiece -- no name in
src/is defined with two different parameter counts, and a prototype and its definition agree -- so the re-record is 209 lines gaining a column plus the two new entries.check-docs.shneeded no edit: it asks the extractor for a single kind, and that road prints the name column alone.