Skip to content

Releases: chrispaig3/Axiom

Axiom 0.7.3

Choose a tag to compare

@github-actions github-actions released this 03 Sep 22:43
50ae6a2

0.7.3 — 2026-09-03

AX3064: a concurrent binding may not capture a reference the parent holds

(parallel p ((a e1) (b e2)) body) under --threads lowers to real
pthread_create, and the emitted runtime's axiom_retain and
axiom_release are a plain load-add-store — not an atomicrmw. A
binding that captured a heap value the parent also held raced the count,
lost an increment, and freed a block a live reference still named. This
compiled silently and ran:

(let ((shared (strConcat "hello" " world")))
  (parallel p ((a (len shared)) (b (len shared))) ...))

It is now two AX3064s, one per binding, naming the captured variable
and its type.

The rule is unconditional, not --threads-dependent. The checker
does not see codegen flags, and a diagnostic that appears and disappears
with a build flag is unlike anything else in the AX3xxx band. The
process lowering is merely permitted to be laxer.

The corpus was measured before the rule was believed. Zero of the
611 files in the corpus as it stood before this change are refused. An instrumented
compiler reports twelve captures at a spawn across four files —
tests/stdlib/470-parallel.ax (base, ×5),
tests/stdlib/471-parallel-trap.ax (v), and the two syntax zoos
(n, ×3 each) — and every one classifies as a non-reference. The
sweep is not vacuous: appending a heap-capture probe to that same list
produces exactly one hit, and tests/diagnostics/642-parallel-capture.ax
is that probe made permanent — re-run the sweep today and it is refused
twice, by design, which is why the count above is stated against the
corpus as it stood. Classes 2+k and the negative witnesses never occur
at a spawn in it, so refusing them costs nothing today, and the rule
refuses every class but 0.

Two corrections came out of building it. EV_LAMARG is −2, not a
positive number, so "negative" is two distinct things — the enclosing
lambda's own parameter, whose class the application decides, and a
minted placeholder that emits no retain at all. Both are refused because
neither is a promise. And the obvious lookup, scopeFindIdx, is
wrong: it searches the module walk's entries below frameBase, so a
top-level (-> Int Int) classifies as a reference and
tests/stdlib/470-parallel.ax is refused at spin. scopeFindFrame
— the current frame alone — is the rule value resolution already
follows, and row 4 of tests/diagnostics/642-parallel-capture.ax pins
the distinction.

Gates: diagnostics 205 passed; stdlib 108 passed; check-parallel 20
checks; check-doc-drift registry 78 constructed / 78 explained, sets
equal both directions.

A constructor built inside a lambda claimed to hold no references

wrap    (Some x) outside a lambda    shape word 131076   record, word 1 is a reference
viaLam  (Some x) inside  a lambda    shape word      4   record, LEAF

Same value, same type, different header. The leaf claims the block holds
no reference, so the payload's share was never handed back.

Three emitters read the same per-field evidence witness. emitPrimRetainRef
enumerates three classes; ctorShapeEmit and fieldRetainCode
enumerated two and let EV_LAMARG fall into a silent else. A third
consumer, ctorFieldParks, tests fieldRetainCode == -1 to decide
whether a field parks — it corrects itself once the witness is passed
through, which is the same treatment wv >= 2 already had.

The shape word and the retain landed together, and that is the whole
care in this fix.
Setting the bit alone would convert an undercount
into a genuine over-release: the walk would hand back a share the store
never took. The proof they landed together is the payload's reference
count, which moves 1 → 2 while the block's own count is unchanged.

case shape before → after
(Some x) inside a lambda 4 → 131076
two ref fields, one lambda deep 262152 → 393224 (bit 17 was lost)
two ref fields, two lambdas deep 8 → 393224
Some over an Int (control) 4 → 4

The Int control still reads LEAF because the bit is emitted as a
run-time read of the lambda's evidence word rather than folded into the
constant — which is what stops the fix from over-approximating.

Blast radius zero, measured rather than asserted: self_host/main.ax
compiled by the pre-fix and post-fix compilers is byte-identical,
217,935 lines both ways. self_host/ contains no constructor whose
field witness is EV_LAMARG, which is also why no gate could have
caught this.

tests/stdlib/475-lambda-ctor-shape.ax is non-vacuous: on the pre-fix
compiler four of its eight lines differ while four controls hold,
including the Int line that would catch an over-release.

Gates: closure-reclaim 5 checks including both source-substitution
ablations; container-reclaim; fallible-reclaim; self-host 179 passed.

The site that argues it is built for agents was serving agents an empty div

dist/index.html contained <div id="root"></div> and nothing else.
Every word — the effect system, the zero-undefined-symbols claim, the
benchmark, the honest list of what Axiom is not — existed only after
React executed. Google renders JavaScript on a delay and a budget; Bing
is best-effort; and every crawler that feeds a language model fetches
HTML and executes none of it.

Visible text a JavaScript-less reader receives: 0 → 17,498
characters
.

The render is not new code. scripts/smoke.mjs has rendered the whole
tree in Node since it was written; scripts/prerender.mjs is its first
fifteen lines with the output kept instead of asserted on, using
renderToString so hydration matches. Two floors guard it — the mount
point must still exist, and the markup must exceed 20 KB against a real
68,065 — because a render that silently produced nothing reads exactly
like one that worked.

The hydration mismatch this would have introduced is fixed in the same
change: useTheme resolves to 'light' in Node, so the server always
emitted the Moon glyph and every dark-mode reader would have hydrated
into a console error on the one page whose gate exists to catch console
errors. The button now carries both glyphs and CSS chooses, driven by
the data-theme the inline script already stamps before first paint.

Six showcase programs that ran, replacing five feature demos in costume

The tabbed samples parsed the literal string "8080" and logged
"starting"/"done". Nobody parses a literal, and a reader can tell
when an example was reverse-engineered from the thing it is selling.

Six small real tasks, each written against the standard library as it
is, compiled, run, and formatted by axiom fmt — so the site shows the
formatter's normal form and each result field is observed stdout. The
order is an argument rather than a menu: types, failure, effects, data,
memory, concurrency.

inbox.ax could not have been written yesterday: counting into a Map
and reading its keys back needs mapKeys, which landed this morning.

Two dead documentation links went with them — #error-handling does not
exist in the reference, and the parallel heading slugifies whole, so a
bare #parallel landed silently at the top of a four-thousand-line
file. Neither is reachable by smoke.mjs, which checks in-page anchors
and cannot follow an external one.

Axiom 0.7.0

Choose a tag to compare

@github-actions github-actions released this 03 Sep 20:38

0.7.0 — 2026-09-03

A segfault the gate battery was structurally unable to see, the Rust
FFI's first lint gate of any kind, and a front page that stopped being a
reference manual. Four of this release's findings were stale artifacts
sitting behind a CI failure that stopped the run at gate 20 of 70 — each
found by fixing the gate in front of it.

A lambda's parameters were typed by the function around it, and an Int was retained as a pointer

(:: g (-> String Int Int))
(fn (g s k) (let ((f (lambda (a b) (+ a b)))) (+ k (f 999999999 2))))

exited 139. Change g's signature to (-> Int Int Int) and the same
lambda prints 1000000002. Codegen keeps the parameter NAMES it is
emitting in word 17 and the declaration whose signature TYPES them in
word 36; emitLamDef set the first to the lambda's parameters and left
the second on the enclosing declaration, so curParamRefClass typed the
lambda's a as g's String, set the closure record's map bit for it,
and emitted axiom_retain on the integer. The parser curries
(lambda (a b) ..), so every multi-parameter lambda took the path,
and the classification was wrong whenever the enclosing arrow disagreed
at that index. Below 4096 the runtime's immediate guard swallows it.

Fixed with pairSlot 7, set while a lifted lambda's body is emitted and
read by the two classifiers that lacked the identity check their three
siblings (paramFlowBit, paramIsString, paramClassOf) already made.
A flag rather than their name comparison, because a lambda shadowing the
function's parameters with the same arity and spelling passes that test
and is still a different binding — term 5 of the fixture.

The compiler's own source never took the path, and that is measured:
compiling the identical self_host/main.ax with the pre-fix and post-fix
compilers gives byte-identical IR, 8,729,830 bytes. So no gate, no
self-host fixpoint and no bootstrap could have gone red on it. It is the
zero-population region the 2026-08-10 corpus survey named FIRST
("multi-parameter lambda, 0 of 97 lambdas"), producing its second bug
after the first was fixed. Gate: tests/stdlib/472-lambda-param-class.ax,
five terms, exit 139 on the pre-fix compiler having printed nothing.

A Map you cannot enumerate is not a Map, and an Option nothing reads is not a type

mapKeyAt, mapValAt and mapStateAt were all private, so the public
surface offered mapLen and mapCap — a loop bound with nothing to read
inside it — and no caller outside stdlib/Map.ax could name a table's
contents. The evidence it was felt is in the module: mapSumKeys and
mapSumVals exist, and their comment says the two sums "pin down a small
map's contents well enough to test with".

Added: mapLiveFrom (a cursor answering (Option Int)), mapKeys and
mapValues (sharing one traversal and one slot order, so they zip), with
mapKeyAt/mapValAt made pub. mapStateAt stays private — tombstones
are an artifact of how delete keeps linear probing terminating.

Separately, fourteen public functions across Str, Vec, Intern,
Utf8 and Path answer an (Option a) and the standard library shipped
zero ways to read one. isSome, isNone, optUnwrapOr, optMap,
optAndThen and optOr join Err.ax beside okOr and toOption.

check-agent-policy.sh then contradicted a sentence in the new module
comment claiming the cursor is allocation-free: mapLiveFrom is charged
Alloc because Some is a heap block — call i64 @axiom_alloc(i64 16)
per live slot, read out of the emitted IR. The comment now says so. The
gate was right.

Gates: 107 stdlib tests; check-stdlib-api.sh with both floors
re-derived — the name sweep 400 → 700 against a measured 749 (three
hundred and forty-nine names could have been deleted with it green), and
the documentation ratchet 290 → 560.

The Rust workspace had no lint gate at all, and the freestanding runtime read a failed syscall as a short write

CI ran cargo test and nothing else over ~9,500 lines of hand-written
Rust, most of it unsafe. Moving to edition 2024 (for
unsafe_op_in_unsafe_fn, a hard error there — 116 unmarked operations)
with a declared MSRV of 1.85 and a [workspace.lints] policy then
found, and this release fixes: 33 unsafe blocks with no // SAFETY:
line, 36 undocumented public items, 7 missing_safety_doc, and six libc
shims declaring *mut u8 where the ABI is *mut c_void.

Every boundary handle was Send + SyncAxStr, AxVec,
AxFn1/2/3 and the owning AxString — while axiom_retain is a plain
load-add-store. A NotThreadSafe marker fixes it at zero representation
cost; four compile_fail doctests hold it, and weakening the marker
makes all four report "compiled successfully, but it's marked
compile_fail".

And a real bug in nostd_runtime.rs, behind a feature no member
enables and which therefore did not compile under edition 2024 at all:
both Darwin call3 implementations were missing the b.cc/jnc
branch-and-negate that targetSyscallAsm emits. A BSD kernel signals
failure through the carry flag with a POSITIVE errno, so a write
failing with EBADF came back as 9 and write_stderr's if n <= 0
read it as nine bytes written. Verified by reading the emitted assembly
for both targets (b.lo/jae are the assembler's spellings).

self_host/rustbind.ax emits unsafe extern "C" now, because edition
2024 refuses the bare form and a generator writes into somebody else's
crate. Gate: check-ffi.sh runs clippy -D warnings and cargo fmt --check over three surfaces — the workspace, --features host, and
--features nostd-runtime — because the workspace sweep reaches neither
feature, and each hid a finding.

The front door was a reference manual: README 75,806 bytes to 7,587

Two thirds of it was ## Implementation Status, whose rows average 1,195
characters. That, the type table, the CLI list and the diagnostics
showcase moved verbatim to docs/status.md; ### Targets stayed,
being the one copy of the supported-target list and of the sentence that
defines supported. Five gate readers were repointed in the same commit,
and two of them caught the move doing damage: check-release-targets
refused the condensed Targets section for dropping the darwin-x86_64
exception, and check-version refused a README with no version banner.

check-doc-drift.sh gained a claim — the compiler's own line count, now
recomputed in every prose document that states it — and lost a blind
spot: claim() strips a thousands separator before comparing, so
97,017 and 97017 are the same claim where the readable spelling used
to be invisible.

Three goldens had decayed while the gate ahead of them was red

26df546's CI died at gate 20 of 70, so gates 21-70 never ran on it.
Behind the failure sat three stale artifacts, each found by fixing the
one in front: tests/fmt/corpus-fmt.golden at 64 unpinned files against
a ceiling of 60; the three symbols-zoo goldens 62 rows short because
Platform.darwin.ax gained an (import Err) and symbols lists a
transitive closure; and tests/agent/stdlib-effects.allow recording
which HOST blessed it — both linux legs failed on a two-line diff that
was only a file name, Platform.darwin.ax against
Platform.linux-aarch64.ax, with the effect identical. The derived set
now collapses Platform.<host>.ax to Platform.ax.

Also check-net.sh moved to the serial set: it serves 20,400 real HTTP
requests and asserts every byte returns, which under six-way parallelism
reported "echoed 5983 of 10000" and passed alone immediately after. The
header's own rule — the tell is the RATIO, not the clock.

ERR-ADOPT-1: the failure column reaches zero, and the question it waited on is answered

compat/SENTINELS reads 3 absence + 0 failure (from 7 + 9). The
nine failure rows waited on one question — may println's effect row
widen? — and it is answered with a measurement rather than a
workaround. With sysWriteFd ported alone, the row widens from IO to
Alloc,IO on 11 functions in the compiler's whole closure, six of
them public (sysWriteFd, sysWriteAllFd, writeStr, printLit,
printlnLit, rpcPut), and zero restrict(no-alloc) claims are
refused: nothing in the tree claims to print without allocating. What
widens the row is the failure path — a failed write builds an
Error through sysResult — and the success path builds nothing,
because sysWriteAllFd matches the call directly and reads two
registers. That is what the 2026-08-30 attempt could not say, when an
(Ok n) block per write turned ten gates red. So println's row
may widen, because println can allocate, and only when a write
fails.

Ported to (Result Int Error): sysWriteFd, sysReadFd, netAccept,
netAcceptFrom, netPollWait, sysNowMicros, sysNowMonotonic, and
the seam under the first two, platformWriteFd and platformReadFd
in all five Sys/Platform.*.ax (the four ENOSYS stubs lose their
pure tag, because an Err is built; Windows answers Ok 0 at end
of file as before). sysWriteAllFd and writeStr keep their Int
channel — println's value is that Int in 804 expansions — and
sysWriteAllFd holds its match in a let so the partial-write
recursion keeps its loop. The compiler's own stderr writes, Tui's
flush and the echo server's reply go through sysWriteAllFd now,
which retries a short write where they used to drop it. Every reader
matches the call directly (keyInFill, Http, Rpc, the REPL), so
no bytes-arrived path builds a block. The alternative measured and not
taken — a private raw twin under sysWriteAllFd to keep println at
IO exactly — was defeated by the seam: platformWriteFd builds the
same Error on its own failure path, so the row widens through it
either way. Every widened public row is declared in compat/BREAKING
under 0.6.4; docs/error-model.md §10.1 carries the decisio...

Read more

Axiom 0.6.3

Choose a tag to compare

@github-actions github-actions released this 01 Sep 17:35

0.6.3 — 2026-09-01

Contracts arrive, the error model starts being adopted rather than
merely specified, and the emitter stops asking the same un-indexed
question at every call site.

The through-line is narrower than the list looks: almost everything
here was found by ablating a check to see whether it could fail. Four
of the claim mechanisms this language ships — a contract, a
restriction, an AXTAG on a macro template, an AXTAG typed at the REPL —
turned out to accept a program that violated them, and eleven gates
would have reported one fault in a tree that had two.

The five calls that answer a descriptor, and eleven gates that would have reported one fault in a tree with two

ERR-ADOPT-1's third slice: sysOpenPath, netSocketTcp,
netSocketTcp6, netPollCreate and netSignalOpen answer
(Result Int Error). stdlib/Sys.ax's census: 13 → 8 failure.

These were left until last because they are the head of a resource
lifetime, and that is exactly what makes them cost more than the first
two slices. A call whose whole answer is "did it work" is one
expression at each site; a call that answers a descriptor retypes
lsn, cli and pfd and everything downstream of them. Nine fixtures
moved from (let ((lsn netSocketTcp)) BODY) to a match whose Err
arm has to say what the program does when there is no socket — which is
the point, since seven of netSocketTcp's seventeen call sites never
tested the result at all.

sysOpenPath keeps a private raw form, for the reason
netSetNonBlockingRaw does. Ten functions in Sys.ax open a
descriptor, test it, and convert the errno into their own answer;
routing them through the public wrapper would build a Result and take
it apart again to reach a conversion they already do. Measured:
sysReadFile, sysFileExists, sysReadDir and sysGetCwd are
unchanged in axiom symbols across this port.

Two defects the port surfaced, both of a class this repository has
recorded before.
In 311-preforked-server.ax the connect loop's new
Err arm did not advance sent, whose bound is the loop condition —
the hang shape Job.jobSubmit produced when 302-job hung rather than
failed. In 315-signal-in-poll.ax the assertion (>= sh 0) became a
check that cannot fail once sh is an Ok binder, because
sysResult builds Ok only for a non-negative answer; the assertion
moved to the Err arm, where it can.

netAccept stays out, re-measured rather than assumed. Porting it
gives it #effects=Alloc,IO, and tests/net/echo-server.ax reaches it
below the per-connection __axiom_arena_mark — an (Ok fd) per
connection the reset never rewinds. netAcceptFrom and netPollWait
are the same case; sysWriteFd and sysReadFd the per-write one.

stdlib/Sys/Platform.darwin.ax's three cannot be finished before
sysWriteFd moves, and this was probed.
Porting platformWriteFd
produces two errors at once — AX3010, the ;@axiom:pure tag all
three carry (on Darwin they are -ENOSYS stubs, and a Result
allocates), and AX3004 at stdlib/Sys.ax:160, which is sysWriteFd
forwarding it wherever usesSyscallAbi is 0. Porting the callee
forces the caller the migration excludes: slice 1's netAcceptFinish
finding running backwards. Behind both, check-stdlib-api.sh requires
all five Sys/Platform.* files to declare the same names.

A gate probe rotted the moment the standard library gained a
caller.
check-compat.sh makes unwrapOr private in a copy of
stdlib/ and expects REMOVED F unwrapOr; the first version of the
Tui/Term.ax edit called unwrapOr, so the ablation stopped compiling
and the probe answered FATAL. That is the gate working — its own
comment predicts this rot for vecOwnsRefs — and Term.ax uses a
match now, which Job.ax already argues for one line above its own.

Eleven gates could report one fault in a tree that had two

Swept every gate for the shape found in check-compat.sh last release:
a pipeline headed by a command that exits non-zero on a difference
diff, grep, cmp — run as a bare statement in a failure branch
under set -euo pipefail. The pipeline's non-zero status trips set -e, so the script dies after printing one FAIL line and every check
below it silently never runs, with an exit code indistinguishable from
an ordinary failure. Reduced:

set -euo pipefail
echo "check one: FAIL"
diff a.txt b.txt | sed 's/^/     /' | head -3
echo "check two: this line is the one that never runs"

prints check one, truncates the diff mid-hunk, and exits 1 without
reaching check two.

Of 79 scripts, 14 carried the shape and 11 are braced with || true:
check-agent-calls, check-c-abi, check-effect-fixpoint,
check-ffi (two), check-fmt, check-recover (two),
check-seed-provenance, check-stdlib-api and check-windows-hello.
Every one is report-only — the verdict beside it is set by bad,
status=1 or exit 1 — so the brace cannot change an answer, only
stop the script dying before the rest of its checks run.

Three were false positives, and each for a different reason, which
is why the sweep is reported rather than the count: check-c-abi.sh
opens set -uo pipefail with no -e (it turns -e on later, at line
164, which is what put its line 182 back on the list),
check-agent-policy.sh:280 is a data pipeline whose comm exits 0
normally, and check-thread-local.sh:149 prints a grep whose match
the enclosing condition has already counted.

Five more calls that answered only "did it work", and a census that had been filing a lookup as a failure

ERR-ADOPT-1's second slice, and the same rule as the first applied to
what was left: every call in stdlib/Sys.ax whose entire answer is
whether it worked. sysCloseFd, netPollAddRead, netPollDelRead,
sysSignalBlock and sysKill answer (Result Int Error).

stdlib/Sys.ax's census: 19 → 14 by the port, then 14 → 13 failure
and 0 → 1 absence by a census fix.
Two movements in one row, and they
are different kinds of thing, so both are recorded in
compat/SENTINELS. The library stands at 16 failure + 10 absence.

Six WIDENED rows, and the sixth is collateral worth naming.
sysFileExists gains Alloc because it closes the descriptor it
opened. It is the only such row in the library: verify-compat.py generate over stdlib/ before and after the sysCloseFd port differs
in exactly two rows, and every other function here that closes a
descriptor already allocated. No no-alloc module reaches either name.

No callee undid an exclusion this time, and that was checked rather
than assumed
— the standing cost of the previous slice's finding.
After the port netAccept is #effects=IO, netAcceptFrom is
#effects=IO,Mut and netPollWait is #effects=IO,Mut, all unchanged.

netPollSignalAt was never a failure. It answers "the signal named
by event i, or a negative when that event is not a signal at all";
every bad-path answer it writes is a hand-written -1, and all five
call sites read it as a presence test — 315-signal-in-poll.ax:131
asserts < 0 for "a socket event is not read as a signal". That is
ERR-REC-3's absence, wanting Option. sentinel_census filed it
under failure because a body's mere mention of a syscall beat its
-1 returns, and its Linux arm reads a signalfd whose result is
compared and never returned.

The census follows the syscall result to the answer now, through a
let binder as well as directly. The direct-only version was written
first and also moved sysNowMonotonic
, which is a real failure
forwarding clock_gettime's errno through exactly that binder — a
false positive at a population of two, which is the argument for
following the binding. The rule is narrow by construction: a body with
no -1 in return position never reaches it, so netAccept is
untouched. Over stdlib/ it moves exactly one row.

check-compat.sh's census floor expired on this slice. It read
total_now < 30 against a population of 38, "set under today's 38 by
the margin a real port would move it"; this slice takes the census to
26, so the gate went red for the migration succeeding. A floor that has
to be hand-lowered whenever the thing it measures moves in the intended
direction will eventually be lowered without being thought about.

What replaces it is stricter: the computed census must agree with
compat/SENTINELS row for row.
That is the rule ERR-ADOPT-1 and
that file already state, and asserting it directly also catches a port
that lowers the count and leaves the file stale — which the floor
passed in silence. The "did the rule stop matching" question the floor
was really asking is a named ablation now: a public function forwarding
a raw syscall is planted into a copy of stdlib/, and the census must
count it. Both were ablated: a stale row goes red, and breaking
SENTINEL_SYSCALL fails the plant.

The gate died halfway through reporting, and that is how the second
ablation was found.
Under set -euo pipefail, the
diff a b | sed | head that prints a census disagreement exits 1 and
takes the whole script with it — so a run of scripts/check-compat.sh
stopped at its first FAIL and reported one fault in a tree that had
two, the probes below that line never running. Three such pipelines in
scripts/check-compat.sh are braced with || true now. It is this
repository's "a gate that reports LESS than it knows" hazard, occurring
inside the gate written to refuse it.

Callers: 14 sites over 7 files needed an edit, out of 58 raw call
sites — the rest are results that were being discarded, which now say
so in the type. stdlib/Tui/Term.ax is the one stdlib caller, and its
mkKeyIn already allocated. isOk/isErr at every site that compared
against 0; two if arms became { (sysCloseFd fd) 0 } where a
discarded close sat opposite an Int.

origin/trunk

Seven socket calls that answered a negative errno now...

Read more

Axiom 0.6.1

Choose a tag to compare

@github-actions github-actions released this 31 Aug 18:34

0.6.1 — 2026-08-31

  • A program contains only what it uses. A hello world was a
    103,592-byte binary holding 388 defines, of which a walk from
    main reaches 22. It is 34,640 bytes now, and the compiler emits
    242 fewer definitions of itself. The linkage was only half the
    cause: 375 of 388 definitions carried external linkage, which does
    block dead-code elimination — but emitSymbolTable writes a
    ptrtoint for EVERY function, so the backtrace table pinned all 388
    addresses and internalising them was worth 6%. The removal is the
    emitter's now, before the table is built. Reading the emitted IR
    rather than the AST is what makes it safe: in IR a call, a closure
    record's ptrtoint and a thunk are the same token, so a callback
    needs no special case, and a wrong root is error: use of undefined value out of opt — a failed build, never a binary that links and
    crashes. --emit-staticlib prunes nothing, which is the one named
    consumer, so no flag was added. scripts/check-dead-code.sh asserts
    on the LINKED binary via nm, with a walk deliberately tighter than
    the compiler's so it is not agreeing with itself.

  • Two soundness holes: check said OK and the program segfaulted.
    (:: g (-> Int Box)) with body 7 was accepted and exited 139 — the
    literal dereferenced as a handle. The same mismatch in ARGUMENT
    position was refused all along, so the rule existed and was not
    applied in one place; checkDeclaredReturn compared by a name list
    rather than by tyCompat. 22 type pairs flipped to AX3004, and no
    site in the tree was newly refused because the exemption is exactly
    this tree's Int-as-handle convention and nothing more —
    tests/selfhost/987-handle-convention-roundtrip.ax pins that
    exemption at run time, and removing it turns the fixture red.
    Separately, emitApplyChain passed an empty evidence vector, so
    over-applied and cast-spine arguments were classified "not a
    reference" — 462-surplus-closure-arg.ax exits 139 built by 0.6.0
    and 15 built by this release. Neither was caught by 55 green gates,
    because a soundness hole lives where no fixture thought to look.

  • restrict(no-alloc) could not fail, and seven claims were false.
    A function claiming it and constructing a value checked clean:
    findFnEnt answers 0 for every constructor, and walkCallHead's
    branch added nothing. It was not an oversight — MM-EXEC-9a recorded
    constructor-invisibility as a DECISION — but the decision stopped
    being survivable the day the restriction shipped, because a decision
    that makes a checked claim unfalsifiable is the check not existing.
    123 of 3,725 effect rows gain Alloc; seven claims are withdrawn
    with the reason written at each site. The nullary arm is
    load-bearing: (Empty) allocates nothing, so arity decides, not
    constructor-ness.

  • mut on a struct field was parsed and thrown away. A write to a
    non-mut field through an immutable binding compiled clean with no
    diagnostic; parseOneField said so in its own comment. This project
    removed linear, consume and deriving for exactly that. Enforced
    rather than deleted — 29 fields migrated, found by building the
    compiler with the refusal armed rather than by grep, which was wrong
    in both directions. explain AX3012's claim that "structure fields
    are written with memSetWord instead" is corrected.

  • The REPL has a terminal UI. Editing, history with reverse search,
    syntax highlighting painted from the compiler's own lexer, and
    completion including bindings defined earlier in the session — which
    exist in no file. stdlib/Tui/{Keys,Edit,Term}.ax import nothing
    from self_host/. The piped surface is byte-identical: 22 sessions,
    44 streams, 341,394 bytes before and after, zero differing.

  • The language server learns textDocument/declaration and call
    hierarchy.
    Declaration is not an alias for definition here — Axiom
    writes a function twice, so declaration lands on (:: f T) and
    definition on the body, and a signature with no fn yet answers one
    and null for the other. rangeFormatting is REFUSED on a
    measurement: 8,437 form-aligned slices, 21 disagreeing, every one
    comment placement, so format-on-save and format-selection would each
    rewrite what the other wrote.

  • The shared compiler was rebuilt by every gate. gate_source_stamp
    hashes $axiom, and teaching gate_init to honour AXIOM_AXC
    itself a fix, for twelve gates silently measuring the installed
    binary — changed what that means on one side only, so the stamp
    written by the builder could never equal the one a gate computes.
    build-shared-axc.sh's own header prices the sharing at ~16 minutes
    per run.

  • A program contains only what it uses. A hello world was a
    103,592-byte binary holding 388 defines, of which a walk from
    main reaches 22: (import IO) pulls in Err, Fmt, Path and
    Sys transitively and every function of them was emitted, linked and
    shipped. Nothing downstream removed them either — nm on the linked
    executable found 361 of the 366 unreachable ones, in a binary of 397
    symbols, while a program with no imports at all was 34,216 bytes. So
    roughly 70 KB of that hello world was code it could not run.

    Two things pinned it, and only the first is the one a reader expects.
    375 of the 388 defines carried EXTERNAL linkage, which forbids LLVM
    from deleting them because another translation unit might call them —
    and Axiom is a whole-program compiler with no other translation unit.
    But emitSymbolTable also writes ptrtoint (ptr @F to i64) for
    every function in the module, so the backtrace table was a live
    global holding all 388 addresses, and that pin survives any linkage
    change. Measured: marking all 384 non-runtime defines internal and
    running opt -O1 deleted exactly ONE of them and left all 397
    symbols in the binary — 103,592 bytes to 97,368, a 6% win. Dropping
    the table pin as well took the same program to 17,224 bytes. The
    linkage was the smaller half of the problem, which is why the fix is
    the emitter's rather than the linker's.

    pruneDeadDefs in self_host/codegen.ax now walks the module to a
    reachable set and drops the rest, BEFORE the symbol table is built,
    so the table describes exactly the functions that survived — a
    stripped function can never appear in a backtrace. Hello world is
    34,648 bytes, 22 defines and 29 symbols, against a no-import
    program's 17,432 and 17; self_host/main.ax loses 242 of its 3,599
    defines, because the compiler uses most of what it imports.

    The walk reads the EMITTED IR rather than the AST, and that is the
    design rather than an implementation detail. The class that sinks a
    naive reachability answer is the function whose ADDRESS is taken
    rather than called — a callback, a comparator, a capability-record
    field — and the effect walk already gives up on exactly that shape
    and marks it #effects-incomplete. In the IR the class does not
    exist: there is one way to mention a function at all, the token
    @name, whether the mention is a call, a ptrtoint into a closure
    record, or a thunk over a bare reference. One scan finds all of them
    with no per-shape knowledge to keep in step as the emitter grows.
    The failure mode is loud rather than silent for the same reason:
    LLVM's textual IR has no implicit declaration, so a root this walk
    got wrong is error: use of undefined value out of opt — a failed
    build naming the symbol, not a binary that links and then crashes.

    --emit-staticlib prunes nothing and needs no flag to say so: an
    archive exists precisely so another translation unit can call in,
    every pub fn is a C symbol by contract, and the whole-program
    assumption is false there by construction. axiom_alloc,
    axiom_retain and axiom_release are roots in every build, archive
    or not, because a --crate host calls them from Rust where no IR of
    ours mentions them.

    scripts/check-dead-code.sh is the new gate, and its walk is
    deliberately TIGHTER than the compiler's — it roots only the entry
    and those three FFI symbols, not the emitter's generous
    outside-a-define catch-all — so it asks a stricter question rather
    than re-running the pass and agreeing with itself. Both answers are
    22 of 22 today, which is the evidence that the generous roots retain
    nothing. Turning the pass off in a shadow tree puts 361 of 397
    symbols back and names all four of the functions the measurement
    called out.

    check-backtrace.sh had two floors that expired on this: rows >= 200, calibrated on "a probe importing Sys had 275 on 2026-08-24",
    and a found >= 20 anti-vacuousness guard. Both went red on a module
    that had got smaller for the right reason, and the second printed "0
    of 16 table names are in no symbol table" — blaming the emitter for
    the number that was correct. Both now rest on the probe's own
    six-deep chain, which cannot expire when the module's size moves
    again.

  • BREAKING: mut on a struct field is a checked constraint, and a
    field is immutable without it.
    (struct P (x : Int)) with no
    marker took (set p.x 9) in silence — check OK, the program
    exited 9 — because the parser recognised mut on a field, skipped
    it and recorded nothing — which parseOneField said in as many words
    at the time, that the marker was "not recorded ... only skipped". That is exactly
    the shape AX2004's explain text calls worse than no marker at all,
    "a marker that reads as an ownership guarantee and supplies none is
    worse than no marker, because a reader spends trust on it"
    , and it
    is why linear, consume and deriving were removed from this
    language. mut was enforced instead, because unlike those three it
    had a rule worth keeping: a field is immutable unless declared
    otherwise, which is the rule let already follows.

    A store...

Read more

Axiom 0.6.0

Choose a tag to compare

@github-actions github-actions released this 31 Aug 11:26

0.6.0 — 2026-08-31

  • KNOWN ISSUE: module resolution matches case-insensitively on macOS, so
    a project shadows a standard-library module it did not mean to.

    Shadowing itself is intended and documented — the resolution ladder
    puts the entry file's own directory first, and a project is meant to
    be able to supply its own Str. What is not intended is that on a
    case-insensitive filesystem the FILESYSTEM decides the match: a file
    named str.ax or STR.ax satisfies a lookup for Str, so the same
    tree resolves differently on macOS and Linux. Measured 2026-08-31:
    with str.ax beside the entry file, (import IO) fails with
    AX3001 undefined variable strLen on macOS and builds on a
    case-sensitive filesystem.

    The second half is the diagnostic. Nothing in that error mentions
    that Str resolved to a local file, so the message a user gets for a
    shadowed module is an undefined name from inside the standard
    library — accurate and unhelpful. A resolution that silently picks a
    different file than the reader expects should say which file it
    picked.

    Not fixed in 0.6.0, deliberately: it is pre-existing, and changing
    module resolution semantics under release pressure is how a worse bug
    ships. Recorded here so someone who hits it recognises it rather than
    debugging their own code.

  • The seed lineage is verified end to end on every push, and the thing
    that makes that cheap cannot be used to hide a broken link.

    scripts/check-seed-lineage.sh --full replays every row of
    bootstrap/CHAIN from the Rust anchor bb730db, and its cost is
    linear in the number of reseeds this project has ever done - 10m40s
    over fourteen rows, measured on darwin-aarch64 - so it ran nightly and
    a default run replayed the newest row and said nothing at all about
    the thirteen before it. bootstrap/CHAIN.checkpoint is the record
    that run was missing: it names a PREFIX of the table and the sha256 of
    exactly that prefix - the rows and orphan lines verbatim, every short
    hash resolved to a full commit, the git object id of every seed those
    commits carry, the sha256 of every walk list and patch file they name,
    and the anchor - and the gate RECOMPUTES that digest from
    bootstrap/CHAIN on every run before it skips a single row. 0.7s to
    recompute; a default run costs 38s against the 36s it cost when it
    checked nothing about the prefix. A covered row that moved by one byte
    digests differently, the checkpoint is void, and the gate replays
    every row from the anchor and goes red: editing an old row cannot
    shrink the work, only enlarge it. Five probes assert exactly that on
    every invocation, over a synthetic checkpoint the gate builds from the
    table in front of it - the passing direction included, so a verifier
    that refuses everything cannot satisfy the four refusals. The
    checkpoint is advanced only by AXIOM_BLESS=1 scripts/check-seed-lineage.sh --full, over rows that same process
    replayed from the anchor, and never as a side effect of a passing run;
    it never covers the newest row, so the link a push adds is replayed on
    that push. It is a record, not a signature - whoever can edit a row can
    recompute the digest, and what the file buys is that they must do it in
    the same reviewable diff while the nightly --full re-derives
    everything from bb730db regardless. AXIOM_LINEAGE_FULL=1 is
    --full for a caller that cannot pass an argument.

  • Traits and impl are gone from the language. An interface is a
    CAPABILITY RECORD now — a parameterised struct holding the functions,
    bound with fn and passed as a value — so dispatch is application and
    there is no resolution rule to learn. trait and impl are reserved
    and draw AX2004 with the migration in the message. stdlib/Show.ax's
    trait and its four impl blocks are deleted: showResolve in the
    checker renders String, Bool, Int, Float, Char and every
    data/struct from the argument's static type, and always did.
    deriveEq/deriveShow are unaffected — they generate plain functions
    and always did. What is actually lost, stated rather than left to
    be discovered: a user could override the built-in rendering for their
    own type with (impl (Show Color) ...), and there is no override hook
    now; deriveShow still generates showColor to call deliberately.
    compat/BREAKING declares it. Nineteen fixtures went with the
    construct, but THREE were pinning properties that outlive it and were
    rewritten rather than deleted: 010-trait-scope and
    020-trait-duplicate asked whether a global value-namespace occupant
    captures a spelling a let or parameter should shadow, and now ask it
    of an effect OPERATION, which is the occupant a program can still
    declare (isBuiltinName, isMacroName and isEffectName are the
    others the resolver asks about). 373-shared-default-binder could not
    be rewritten, and why is the substantive result — see the next entry.

  • A guard whose only reachable path was traits, and the check that
    asserted it becoming a check that cannot fail.

    stampPatBinderTy keeps a three-state stamp whose third state is "two
    checks disagreed", because a binder node could be checked twice at two
    types and last-write-wins would hand codegen an Int for a binder that
    is really a String — a release of a block the binder still points
    into. The only path was a trait DEFAULT body, which checkImplComplete
    synthesized into every impl without copying the nodes. Measured
    2026-08-31 by building the last-write-wins compiler and diffing emitted
    IR: 278 fixtures, every stdlib/ module and self_host/main.ax itself
    are BYTE-IDENTICAL. So scripts/check-fallible-reclaim.sh's second
    half had quietly become a check that cannot fail — this repository's
    most-refused defect. It is INVERTED rather than deleted: it now proves
    nothing in the tree reaches the arm, with the whole compiler as the
    subject, and goes red the day a future construct re-checks a body per
    instantiation. The arm stays, because it guards a class of mistake
    rather than one construct, and it now has a live check on it.

  • Restriction profiles had zero production use, and the gate only
    worked because of it.
    no-io, no-alloc, no-cast, no-recursion
    and no-foreign shipped in Ada round 1, are enforced, and appeared
    only in tests/diagnostics/. Tagging one function turned
    scripts/check-restrictions.sh red twice for reasons unrelated to that
    function: section 1 stripped #restrict= from only the tagged side of
    its diff, and section 5's manifest credited a fixture with any tag it
    could see through an import. One root cause — both assumed no
    #restrict= existed outside the gate's own fixtures, true only while
    adoption was zero. Both fixed, then 239 tags across the base layer
    everything else calls: stdlib/{Vec,Mem,Str,Utf8,Fmt}.ax and
    self_host/{core,style,lexer,diag}.ax, chosen because symbols --diagnostic-format=ai found 1393 functions tree-wide satisfying both
    no-io and no-alloc and these nine files are the ones whose import
    graph provably reaches no extern (Ffi.ax and rustbind.ax are the
    only files in the tree with one). The five #effects-incomplete
    declarations — vecSortBy, vecSiftDownBy and three in Http.ax, all
    indirect-call sites — are deliberately untagged, because a claim there
    draws AX3051 rather than passing, which is the effect walk correctly
    refusing to vouch for a call it cannot follow. ABLATED, and the number
    is the point: breaking three functions produced 108 AX3049s, each
    naming its own witness path, so the transitive fixpoint is doing the
    work rather than a single accepted claim. 173 programs still emit
    byte-identical IR, diagnostics and AXSYM with every one restricted.
    tests/agent/restrictions.allow 49 → 288 rows.

  • restrict(no-wrap), and it is LEXICAL where three of its siblings
    are transitive.
    +, - and * lower to plain add/sub/mul
    with no nsw, so overflow wraps silently; claiming no-wrap and
    writing a raw operator is an act this body performs rather than a fact
    the effect row carries, which is the same reason no-cast is lexical.
    tests/diagnostics/383-restrict-no-wrap.ax; explain documents the
    distinction and scripts/check-tools-selfhost.sh holds that text to
    the compiler's own.

  • Removing a keyword broke the direction reseed.sh's rule does not
    mention.
    scripts/check-seed-provenance.sh regenerates the seed from
    the commit that last touched the six .ll files using the CURRENT
    compiler; that commit's tree still said trait, the current compiler
    answers AX2004, and the gate went red — while check-bootstrap
    stayed green throughout, because the old seed understands a SUPERSET of
    the language and could still build the new tree. The seed is a TWO-WAY
    compatibility boundary and reseed.sh documents one direction ("the
    seed moves when it can no longer compile self_host/"); the other is
    that the seed's recorded source must stay buildable BY the tree. Budget
    a reseed with any construct removal. bootstrap/CHAIN gains a
    stage2 row and not bridge-needed, so check-seed-lineage.sh still
    replays back to the Rust compiler at bb730db. Verified before
    committing: seeds match SHA256SUMS, the seed built darwin-aarch64
    with no Rust, compiled self_host/ into stage1, and stage2 and stage3
    are byte-identical.

  • gate_init resolved the compiler as ${AXIOM:-.axiom-bin/axiom} and
    never mentioned AXIOM_AXC.
    Twelve gates that call it without
    gate_build_axc silently measured the installed binary while a caller
    believed it was testing theirs. It now resolves AXIOMAXIOM_AXC
    bootstrap and prints which it took and why, every run; and it runs the
    compiler once before handing control to the caller's loop, so a binary
    that cannot exec prod...

Read more

Axiom 0.5.0

Choose a tag to compare

@github-actions github-actions released this 30 Aug 19:41

0.5.0 — 2026-08-30

The effect system finished against its own design — six of the seven
items that design listed, landing in three days — and the first
compiler change the concurrency work needs. Two of them can fail a
build that passed under 0.4.3, which is why this is 0.5.0 and not
0.4.4: Err is no longer a built-in effect name, so
(handle x (Err) 0) draws AX3016 where it used to compile clean, and
(effect IO (op :: ...)) is now AX3054, an error, where it used
to be accepted and unusable. Neither touches the standard library's
public surface — compat/0.5.0.axsym is byte-identical to
compat/0.4.3.axsym, all 591 rows — so no compat/BREAKING line is
owed; the breaks are in the language, and this paragraph is where they
are declared.

Three new diagnostics (AX3053, AX3054, AX3056), one of them a
warning by design and not as a staging step. The effect fixpoint
became a worklist and took the declaration order a generator emits from
56.05 s to 0.10 s at 8,000 functions. The emitted runtime's eight
mutable globals learned a storage class they do not yet use, byte for
byte. And two measurement artifacts that had been quietly steering the
work — the sentinel census and the bootstrap memory ceiling — were
repriced against what the code does rather than what a comment said.

Forty-five gates build the compiler under test, up from forty-two;
fifty-three run in the battery.

Known and shipping, both documented, neither a regression. A
closure application does not release its owned argument — 96 bytes per
operation, measured, with stdlib/Fallible.ax's header carrying the
number and the reason its operation takes one argument. And
sysWriteAllFd answers a short non-negative count when write makes
no progress, which its own doc comment names as "the classic way to
silently truncate output"; it needs write to answer exactly 0 for a
non-zero count, which a regular file cannot do and a non-blocking
socket reports as -EAGAIN instead. Both predate every release from
0.2.0. A third, smaller: ;@axiom:unhandled(trap) is a contract and
is not yet in the compat surface's key list, so check-compat.sh
cannot see one being removed from a third-party effect —
check-test-runner.sh guards the standard library's own.

  • The documents made true (effects item 7). This repository's style is
    falsifiable claims with the probe that established them, which makes a
    false claim a defect rather than a typo — the documents are the
    specification, and work is planned from them. Seven were measurably
    false, and each is corrected where it stood:
    ;@axiom:effect(IO) does not become a custom effect.
    docs/agent-harness.md carried it as a Hazard: "silently
    reinterpreted as a custom effect named IO and reported missing IO". Measured — it checks OK, and symbols gives it
    #effect=IO #effects=IO. Custom tag values match declarations
    case-insensitively, so the value folds to the built-in and there was
    never a trap to case-fold around.
    "Only IO is declarable" was the wrong word, in four places
    (README.md, docs/reference.md, docs/agent-harness.md,
    explain AX3042). Only IO is required: ;@axiom:effect(mut)
    over a body that writes a field checks OK, and over one that does not
    it is AX3010, an error. Alloc, Mut and every custom effect are
    declarable and checked. What is special about IO is that its
    absence is itself a claim.
    The effect census was stale in five placesREADME.md,
    docs/agent-harness.md, explain AX3042 and two typecheck.ax help
    strings all read 3,040 / 1,911 / 1,382 / 299. Recomputed:
    3,421 / 2,095 / 1,664 / 332.
    The AXSYM KIND table was missing two of its eight letters.
    docs/diagnostics.md and .claude/skills/axiom-helper/SKILL.md both
    listed F D C S A T; E (effect declaration) and M (macro) have
    been emitted since 2026-08-26.
    COMPAT-3's recorded hole is closed. It read "Thirteen public
    names are outside the symbol stream… symbols.ax has no arm for
    TAG_D_MACRO". It has had one since 2026-08-26, compat/UNCOVERED
    has been empty since and says so in its own header, and the rule
    now explains why an empty file is worth keeping: it is the assertion
    that nothing has left the stream, which a deleted file could not
    make.
    check-agent-policy.sh called AX3010 a warning. It has been an
    error since 2026-08-25 — a forged claim fails the build before the
    gate reads a line of stderr.
    And a note this month's own work left stale: typecheck.ax's
    never-inferred list still described Err as a spellable built-in.
    AX3054's commit retired it four commits ago.
    README.md's Effects row said Complete while the design it was
    measured against had seven open items. It now says what holds — the
    handler checked against its operation's arrow, per-effect
    over-approximation, AX3053, AX3054, the worklist — and what does
    not: a closure application still does not release its owned argument,
    96 bytes per operation. Gates: check-doc-drift,
    check-tools-selfhost (explain.golden), check-render-selfhost.
  • The sentinel census counts what the code does, not what a comment
    says.
    compat/SENTINELS sizes the -errno/-1Result
    migration and scripts/check-compat.sh gates its direction — a module
    may never gain a sentinel. The metric matched a doc-comment against
    six phrases. Audited 2026-08-30, it was wrong in four ways that
    compound:
    It rewarded silence. netListen forwards __syscall3 and hands
    back a raw fd-or-negative-errno. It was uncounted, because nobody had
    written the sentence. Writing the house line above it would have taken
    stdlib/Sys.ax from 3 to 4 and failed the gate for a commit that
    changed no contract
    . A gate that goes red when you document an
    existing sentinel is a gate asking you not to.
    It undercounted by roughly four times — 13 public functions over 7
    modules by prose, 38 over 7 by body, with stdlib/Sys.ax alone
    going 3 → 26, because almost every net*/sys* call forwards a
    syscall result and almost none says so in the house phrasing.
    One of its six patterns matched nothing at all: answers 0, or
    appears nowhere in stdlib/ (the tree writes "Answers"), so its only
    0-sentinel arm was dead.
    And it counted a constant. sysRandomNum is
    (pub fn (sysRandomNum) 33554932) — the getentropy syscall
    number
    , no bad path — counted because the comment walk climbed a
    ; --- section banner into prose about getentropy twelve lines
    above. That was the whole of the old Sys/Platform.darwin.ax 1, and
    docs/error-model.md had already inherited the error and named it one
    of five failures.
    The new rule reads the declared return type and the body. A public
    declaration counts when its return carries no channel — not
    (Result …), (Option …) or Bool, parsed rather than
    substring-matched — and its body answers a designated value: a
    (- 0 n) in return position, or an unwrapped forward of a
    __syscallN/platform* primitive. It is classified failure when it
    reaches a syscall and absence otherwise, which is ERR-REC-3's own
    line: Result is for failure, "not found" is absence and wants
    Option. Both numbers are gated per module, which the old file could
    not do — it had a Result skip and no Option skip, so an
    in-place port of strFindByte to (Option Int) would have left its
    count exactly where it was, the regression the Result skip existed to
    prevent.
    It excludes, mechanically, what the audit had to argue about: a named
    constant (fallibleSkipped, intMin, and pollReadFilter, which
    is -1 because that is EVFILT_READ) — a literal body with no branch
    to be the bad path of; a value the caller supplies (symTagFrom
    and netAddrText each hold a (- 0 1) that is the seed argument of
    a fold, and each answers a String) — decided by reading the enclosing
    form's head; a call that never comes back; and a syscall that
    cannot fail. It follows one hop through a thin private forward,
    because pathLastSlash is (pathLastSlashFrom p …) and every -1 is
    in the helper — but not through a body with a branch of its own, which
    is consuming the sentinel (pathExt, mapGet).
    docs/error-model.md claimed "the Result migration is complete."
    Withdrawn.
    29 public functions still hand a caller a negative errno,
    26 of them in one module, and none is free: netSocketTcp has 17 call
    sites, seven of which never test the result at all — which is the
    reason the migration exists, not an argument against it.
    And Option is not free either, measured over 20,000,000 calls at
    --opt 2: a -1 return costs 1.4 ns, a (Some v) 10.4 ns,
    7.4×. The arena bump moves zero bytes for that loop — the block is
    recycled through its size class — so a bytes-only measurement reports
    Option as free and is wrong; the cost is instructions, and
    strFindByte has 62 call sites on the compiler's own scanning path.
    The floor also grew teeth: it failed only when fewer than three
    modules
    matched, so the census could have collapsed from 13 functions
    to 3 and still printed ok. It is a function count now, under today's
    38 by the margin a real port would move it.
    What the rule still does not reach is stated rather than left to be
    found: the literal-0 sentinel (vecGet, vecPop, vecLast,
    strByte, jsonGet, jsonArrGet); 114 public macros, among them
    println, whose value is writeStr's bytes-or-errno Int with 804
    expansions in the tree; and members of pub extern, pub trait and
    effect declarations. About 21% of the public surface is in a form
    (pub :: at column 0 does not match. The number is a floor.
    Gates: check-compat (30 ch...
Read more

Axiom 0.3.7

Choose a tag to compare

@github-actions github-actions released this 29 Aug 20:11

0.3.7 — 2026-08-29

The enterprise readiness plan's two remaining buildable items land — the
compile-time ceiling it called "a bug, not an architecture", and the
Fallible effect it said costs nothing — and check-name-scale.sh now
builds an ablated twin of the compiler to prove its new arm can fail,
which makes it the thirty-eighth gate that builds the compiler under
test through gate_build_axc: thirty-eight gates, up from thirty-seven.
Beside them, two more diagnostics carry a machine-applicable fix and
the language server gains three assists of its own - the code-action
surface an editor user reaches for most.

Added

  • stdlib/Fallible.ax — the batch loop's effect. The plan's
    fault-containment section said a Fallible effect whose operation
    answers "skip / use this default" costs nothing today and needs no
    compiler change; nothing in the tree spelled it. Now: one effect, one
    operation, (fallibleMalformed message), performed by the callee that
    finds a malformed record and answered — tail-resumptive, no unwinding
    — by whichever handler the loop installed: fallibleSkip (answers the
    sentinel fallibleSkipped, which fallibleIsSkipped reads),
    (fallibleDefault d), or (fallibleCounting tally next) around
    either, with FallibleTally/fallibleTally/fallibleCount. An
    unhandled operation is still exit 71 (ERR-REC-6 class ii).
    docs/error-model.md ERR-REC-7; tests/stdlib/410-fallible.ax pins
    both handlers, counting, nesting, a logging handler, the trap inside
    and outside a recovery point, and four memory terms.
    examples/batch-fallible/ reads N generated records, every k-th
    malformed, under both handlers, and check-steady-state.sh gains a
    batch probe: 2,000,000 records under fallibleSkip hold 1,376 KiB,
    the same as 200,000, with a keeping twin required to grow past 5×
    (measured 25×).

    The shape was chosen by measurement, not by the plan's sketch. With
    the arena mark cell over 10,000 records: a one-argument operation with
    a literal message costs 0 bytes per operation; the two-argument
    spelling (op message fallback) costs 32 — the inner closure of a
    curried handler, never released; a message built per record costs 80
    — a string a type-variable handler parameter hides from the release
    walk. A batch loop has no arena reset (MM-ALLOC-22), so both would
    have grown the process by every record. Both are compiler facts the
    module documents rather than defects it fixes.

  • Two more diagnostics carry a machine-applicable fix, and IO.todo.
    AX3042 (a function performs IO and does not say so) now carries
    ;@axiom:effect(io) as a line of its own at the start of the
    declaration's line — right below a :: signature, since a tag
    attaches to the next declaration, and inside an indented impl
    member; offered for the entry file only, and only where the name is
    written as a declaration, so a macro-generated function keeps the
    prose. AX3005 (non-exhaustive match) now carries the missing arms,
    one per constructor in declaration order, ((Ctor _ ...) (todo "Ctor")) with one _ per field, inserted before the match's closing
    ) at the first arm's column, with a second fix bringing todo into
    scope — appended to an existing (import IO (...)) list, else a new
    import line. A nested hole is refused rather than guessed at.
    IO.todo is (-> String a): todo: <what> on standard error and
    exit 70 — written with writeStr and exit rather than die,
    because die's effect row is an upper bound the checker will not
    accuse a caller on, and a todo routed through it left an untagged
    function holding the arm checking clean. tests/diagnostics/363,
    364, 366, 367 pin the ~> fields; applying the compiler's own
    fixes to 364 in rounds gives AX3005, then AX3042 on the functions
    the arms made effectful, then a clean file. In the editor these
    arrive as quickfixes with no server change: lspQuickfixesOf
    surfaces every help that carries a span.

  • Three code actions the compiler does not write. The language
    server's textDocument/codeAction answered only what the checker
    carried — a help with a fix span — and one assist from what it
    inferred. Three more, each in SECTION FIX of lsp.ax, each refused
    where its condition does not hold: Import name from Mod
    (quickfix on AX3001 with a bare name; every directory the resolver's
    own moduleSearchDirs answers is searched, a file counted only when
    moduleSrcPath resolves that name to it — the ladder's shadowing
    rule asked rather than restated — and the edit adds the name to an
    existing import list, else a new import line; 0.014 s for a stdlib
    search); Make name public in Mod (quickfix on AX3023; a
    WorkspaceEdit keyed by the DECLARING file's URI inserting pub on
    the fn and its ::, because check refuses either alone —
    measured); Extract to let (refactor.extract on a range covering
    exactly one item in a fn body, hoisted above the innermost block
    statement on the path, refused under a lambda, loop, branch or arm
    and whenever the item references a binder bound inside that
    statement, by NAV's occurrence walk). codeActionKinds gains
    refactor.extract; drive.py applies each edit and requires check
    clean or unchanged behaviour.

Changed

  • The name map answers from an index, and doubling a module costs
    1.9× rather than 3.9×.
    mangleHasIn was a linear scan of every
    bare name asked once per declaration being mangled — the quadratic
    check-name-scale.sh's header recorded at "55.7% of a check at
    N=8000". Measured on the tree before the change, best of three: a
    module of 8,000 declarations checked in 2.12 s (private helpers) /
    3.26 s (public), doubling to 16,000 cost 3.25× / 3.58×. It was not
    55.7% of a check; it was ~93%. A MangleIdx — an Intern over the
    bare names plus a position vector, fed by the five writers and
    threaded beside bares through the resolver — answers in 0.23 s /
    0.22 s at 8,000, doubling at 1.91× / 1.93×; speedup 9.4× / 15×. Every
    public signature keeps its shape. check-name-scale.sh gains the
    plan's N→2N arm (bound 2.80, floor N=8,000) and builds a twin with the
    scan put back, which must fail the arm (measured 3.24× / 3.41×) — and
    the same script run inside a pristine git archive of the previous
    release fails it at 3.25× / 3.58×.

Axiom 0.3.6

Choose a tag to compare

@github-actions github-actions released this 28 Aug 22:32

0.3.6 — 2026-08-28

The three compiler defects the language-server work found and 0.3.5
recorded under Found, not fixed are fixed, the server stops sending
semantic tokens so the grammar is the one source of colour, and the
grammar gains rainbow brackets. No gate was added: every change here
is covered by the thirty-seven gates that build the compiler under
test through gate_build_axc, and check-tree-sitter.sh learns one
more query file.

Removed

  • Semantic tokens. 0.3.5's textDocument/semanticTokens/full and
    /range are withdrawn, by decision rather than defect: the
    tree-sitter grammar is the one source of colour, and two sources that
    can disagree about the same token are worse than one. The SECTION HL
    of self_host/lsp.ax, its drive.py block and its sweep entry are
    gone; the capability object shrinks by one key and the eight goldens
    are re-blessed for that alone. docs/lsp.md says which editors take
    colour from the grammar (Helix, Neovim, Emacs 29, Zed) and which
    therefore have none from this repository (VS Code, which has no
    tree-sitter and for which no TextMate grammar is shipped).

Added

  • Rainbow brackets in the grammar. tree-sitter-axiom/queries/ rainbows.scm names every bracket-opening rule of the grammar as a
    @rainbow.scope — 59 of them, checked against src/node-types.json
    so a hidden or aliased rule cannot be named — and the six bracket
    tokens as @rainbow.bracket; Helix colours each pair by its nesting
    depth with [editor] rainbow-brackets = true, and
    rainbow-delimiters.nvim reads the same captures. Measured on
    stdlib/Vec.ax: 868 brackets in 288 scopes. check-tree-sitter.sh
    loads it against every .ax file beside highlights.scm.

Fixed

  • A macro-generated struct's fields carried the parser's float flag
    where their type belonged.
    expBuildStructFieldsIn substituted
    word b of each field — the float flag — as if it were the type and
    stored the result back in b, leaving ty 0. Measured on the tree
    before the change: a template spelling (f : Float) made check,
    build and symbols die of SIGSEGV in expansion (a dereferenced
    1); a Float arriving through a parameter reached the checker as
    a silent wildcard, symbols printed #fields=n:{unknown},f:{unknown}
    beside the handwritten twin's n:Int,f:Float, and (* p.f p.f) was
    refused with a false AX3004. The builder reads the type from ty,
    recomputes the flag from what the substitution produced (as
    expCtorFlags already does for constructors), and parks the type
    where the parser does. tests/selfhost/396-macro-struct-field-types.ax
    exits 139 on the old compiler and 73 on this one; the symbols zoo's
    RecTwin row now carries Rec's own #fields=.

  • The formatter reserved thirty-two words the language does not.
    axiom fmt refused (fn (g data) data) while check accepted it;
    docs/reference.md has always said a keyword is special only in the
    position its rule claims. fpIsReservedWord reproduced the retired
    Rust compiler's lexer, and the parity bank had pinned that answer as
    070-keyword-in-expr since the day it was materialized. Measured
    over thirty-six shapes: every non-head keyword-spelled identifier was
    check OK (or a checker error) and fmt refused; in head position
    the parser refuses only consume and begin (AX2004) — and the
    printer PRINTED those two, output check cannot read. Two words
    replace the list (fpIsRefusedHead, the two parseInner refuses);
    070 and 131 flip to rewrites, 190197 pin the identifiers
    that print and the refusals the parser shares. 48 parity cases, 24
    rewrites and 24 refusals; the tree-sitter corpus gains the shape (38
    cases). The ten identifiers renamed in f0a2fa3 could be renamed
    back.

  • Every name the emitter invents begins with a byte no identifier can
    spell.
    (fn (f t0) (+ t0 1)) passed check and died in opt with
    multiple definition of local value named 't0' (AX4003): temporaries
    were %tN and a parameter %name, one LLVM namespace, two schemes
    that could spell the same string — and t<digits> was only the first;
    measured one prefix at a time, label_3, f1, c0, d0, p1 and a
    lambda parameter named _env each failed the same way. The fix is a
    construction, not a list: every local value and block label the
    emitter makes up now begins with .%.t0, %.d0, .L3, %.env
    — which LLVM accepts and no Axiom identifier can contain
    (isIdentStart/isIdentChar leave byte 46 out), so the two
    namespaces are disjoint by what they are made of; 28 label sites and
    17 typed-temporary sites go through tmpStr/labelStr. Global
    symbols are untouched byte for byte (check-symbol-names,
    check-freestanding, check-c-abi). tests/selfhost/955-source- names-vs-emitter-names.ax names a parameter after every old prefix:
    exit 4 in opt on 0.3.5, 42 here. The committed seed keeps its %tN
    and the ladder built from it reaches its fixpoint on the new
    spelling (check-bootstrap.sh).

  • bootstrap-from-seed.sh --install copied the compiler onto the
    binary it was replacing.
    cp onto an existing executable rewrites
    the same inode, and macOS keeps a signature cached per inode for a
    binary that was ever run: the rewritten .axiom-bin/axiom passed
    codesign -v and was SIGKILLed — exit 137, no output — on every
    exec, so every check in the tree died silently for three quarters
    of an hour while three gates shared it. The install is a copy beside
    the target and a rename over it now, which is atomic and a new inode.

Axiom 0.3.5

Choose a tag to compare

@github-actions github-actions released this 28 Aug 21:02

0.3.5 — 2026-08-28

The language server grows from four questions to twenty-two, most of
what rust-analyzer answers for Rust, syntax highlighting included. Every per-keystroke request is answered
from the raw parse tree and the document's own bytes with no macro
expansion (MAC-TOOL-3); the two that run the pipeline do so because
the pipeline's output is the answer — a code action reads the checker's
own fixes, and axiom/expandMacro expands because expanding is the
question. Every request is gated by scripts/check-lsp-selfhost.sh two
ways: a derived check that asks it the right question on a document the
driver writes itself, and a sweep that asks every advertised request the
wrong question at every kind of position. 28 passed, 0 failed; the sweep
answers every advertised provider at every kind of position.

No gate was added for this release: the thirty-seven gates that build
the compiler under test through gate_build_axc already include
check-lsp-selfhost.sh, and the sweep is a section of that gate rather
than a new one.

Added

  • The server knows what a name refers to. textDocument/references,
    textDocument/documentHighlight, textDocument/prepareRename and
    textDocument/rename are projections of one walk over the parse tree
    that records every occurrence of a name with the KEY of the binding it
    resolves to, following the language's own scoping: the innermost local
    first, then this document's declarations (constructors included, last
    wins), then "not this document's" — so the two is of two functions
    are two names, and a let that shadows a parameter is a different
    binding from it. definition and hover ask the local half FIRST,
    because a local shadows a declaration of the same name: definition from
    a read lands on ITS binder through a shadowing let, and hover on a
    parameter answers pw : Int from the signature's arrow. References and
    rename reach every OTHER open document whose imports resolve to this
    file, under its own uri; rename refuses (null) a spelling that is not
    one identifier, a keyword, an imported or builtin name — renaming
    across files the server did not open would leave a broken workspace,
    and a stdlib name must never be renamed from a client — and a
    collision, which for a local is found by a second walk with a probe
    that asks, at every emission, whether the new spelling is bound around
    the binding or captured inside its scope.

    What the tree could not say: fn and lambda parameters carry no
    span. Their anchors are recovered from the header's bytes
    (lspHeaderParamSpans) and used only when the count agrees with the
    tree; a curried lambda's nodes take the one scan's spans in order.

    Measured on this repository's own sources, 60 positions each, every
    one of the six requests costs 0.41x–0.63x of the didOpen on the same
    file (typecheck.ax 14 ms against 26; codegen.ax 18 against 32).
    drive.py's NAV block derives every expected range from four documents
    written in the driver; the cross-file rename is APPLIED in Python,
    written to disk and reopened — the checker publishing nothing for the
    renamed pair is the proof that no occurrence was missed.

  • Six ways to read a document without changing it.
    textDocument/signatureHelp answers the call the cursor is inside — a
    fn of the document, one of its constructors, or an imported fn
    with its module named — as (add x y) : (-> Int Int Int) with the
    type cut from the :: beside the fn, each parameter as the UTF-16
    offset pair that slices the label to its name, the paragraph above
    the declaration as documentation, and activeParameter counted from
    the bytes by the same string-and-comment-aware scanner every other
    request uses. textDocument/inlayHint shows three things the source
    does not spell: x: before each argument of a call to a declared or
    imported fn (never before a variable spelled like its parameter),
    : Int after each parameter in a header and -> Int after it, the
    last two from the signature's arrow. textDocument/foldingRange folds
    every form and brace block whose opener and closer sit on different
    lines, comment runs as comment and import runs as imports, from a
    bracket scan — so a half-typed file still folds. selectionRange
    answers word → form → … → document; documentLink turns every
    (import M) the resolver's own search can find into a link over the
    dotted name as written; workspace/symbol searches every open document
    and every module each imports for a case-folded substring, capped at
    200.

    The one measurement that changed the code: parameter-name hints look a
    callee up once per call site, so the declaration list is indexed per
    request. Keyed on the name's first byte, this repository's naming —
    lsp*, vec*, f0..f2049 — puts every function in one bucket:
    0.050 s for a whole-document request over 2,050 headers against a
    0.031 s didOpen. A djb2 hash over 1,024 slots: 0.012 s. Ratios on
    the gate's document: signature help 0.29x, folding 0.10x, selection
    0.01x, links 0.07x, workspace/symbol 0.54x at 200 items, a
    whole-document hint request 0.25x (0.81x on a variant where every
    function calls another; a 60-line editor window 0.54x).

  • The server formats, fixes, runs and expands. textDocument/formatting
    answers the formatter's output as one edit over the whole document,
    from the same fmtFormat that axiom fmt runs, [] for an
    already-formatted file and null for one that does not parse — the
    gate holds the edit's text equal, byte for byte, to what fmt wrote to
    a copy. textDocument/codeAction offers the compiler's machine-
    applicable fixes as quickfixes — a help carrying a fix span, exactly
    what AXDL prints after ~>, becomes a preferred CodeAction, so a code
    that gains a fix in typecheck.ax gains a quickfix without a line
    changing here — and an Add type signature for f assist for a fn
    with no ::, written from the type the checker inferred, in the
    parser's own spelling (-> Int Int) (tyRender's (Int -> Int) is
    refused by the parser, which the assist found). The gate holds
    AX3012's fix to the derived range of the BINDER — not the set the
    diagnostic anchors on, thirteen columns away — with text mut x,
    AX3001's to the misspelt call, and the assist to axiom symbols'
    type rewritten; then applies all three in Python, reopens the result
    and requires no diagnostics at all. textDocument/typeDefinition lands
    on the data, struct or type of a signed fn's result, a header
    parameter or a constructor, null for builtins. textDocument/codeLens
    puts a ▶ Run lens on main and an Expand macro lens on every
    pub macro; the commands are the client's to run, as rust-analyzer's
    are, and the server runs nothing.

    axiom/expandMacro, the analogue of rust-analyzer/expandMacro,
    answers what a macro generated as Axiom source: on a top-level
    invocation its own products, on a macro declaration everything it
    generated. It required the first ASTNode-to-source printer anywhere
    in the compiler — format.ax prints from its own token forms,
    symbols renders types alone — whose promise is that its output parses
    and means what the tree meant. The gate reopens the rendering as a
    document and requires a clean parse with an outline of exactly the
    generated name; measured beyond the gate on a template holding a
    mutable let, a block, set, while, match, cond, a lambda,
    struct construction, field access, an escaped string, a char, a float,
    a negative literal and a generated data, type and struct, check
    on the rendering reported exactly the diagnostics it reported on the
    template. A joined product name records span 0, so an invocation's
    products are attributed structurally — the fresh parse is expanded
    with every other invocation removed — rather than by position.

    Costs on the gate's 2,050-declaration document against a 0.031 s
    didOpen: formatting 0.66x, codeAction 0.74x (1.39x before the
    is-there-a-signature question was asked before the walk),
    typeDefinition 0.50x, codeLens 0.18x, expandMacro 0.69x.

  • Syntax highlighting, by what a name resolves to.
    textDocument/semanticTokens/full and /range, the protocol's
    highlighting channel and the feature rust-analyzer is known by. Every
    identifier is classified by WHAT IT RESOLVES TO — the same occurrence
    walk that answers references, expanding nothing — and not by how it
    is spelled: a parameter, a let (readonly unless mut,
    modification at its set), this document's function, type or
    macro, a constructor, an effect, or a name from elsewhere
    (defaultLibrary). Keywords, strings, char literals, numbers, ;
    comments, #| |# blocks, AXTAG lines (decorator) and
    operator-spelled names come from one byte scan in lspFormEnd's
    order, so nothing depends on a parse: on the keystroke that breaks
    it, (fn (add x y) ...) keeps add a declared function and y a
    parameter, and a parameter READ in the body falls to variable,
    which is the honest answer once there is nothing to resolve it
    against — highlighting that flickers on every unbalanced paren is
    worse than none. The legend is fourteen types and four modifiers,
    advertised in initialize; no delta request is offered.

    Measured on the gate's 2,050-declaration document against a 0.023 s
    didOpen: the full request, 22,556 tokens, 0.57x; a 60-line range,
    0.07x. drive.py derives 54 anchors over all fourteen types from a
    document written there, decodes the delta array by a second
    implementation, holds every token sorted, non-overlapping, inside
    its line and spelling its class, holds range equal to full's
    tokens on its lines, and holds the broken twin's keywords and
    literals unchanged. The gate's sweep fires full and range with
    every other request.

  • **One door for w...

Read more

Axiom 0.3.4

Choose a tag to compare

@github-actions github-actions released this 26 Aug 23:33

0.3.4 — 2026-08-26

Changed

  • The release published the whole changelog as its notes, and the
    changelog outgrew GitHub.
    release.yml passed CHANGELOG.md
    entire to --notes-file, and on this release that hit the ceiling:
    HTTP 422: body is too long (maximum is 125000 characters) against
    a 132,366-character file. All three platform builds had already
    succeeded, so the failure was three sets of correct artifacts and no
    release.

    It was always the wrong content as well — a reader opening v0.3.4
    wants v0.3.4's notes, not every note since 0.2.0. The publish
    step extracts this version's own section now (7,032 characters), and
    refuses to publish an empty body: a missing or empty section
    stops the release rather than shipping notes that say nothing, which
    is the same rule this repository applies to a golden with no
    content. Checked against every released version — 0.2.0 through
    0.3.4 all extract, the largest being 0.3.0 at 78,160.

No gate was added for this release: the thirty-seven gates that build
the compiler under test through gate_build_axc already cover it,
check-compat.sh holding the nine declared breaks and
run-stdlib-tests.sh the behaviour at 82 fixtures.

  • stdlib/Sys.ax stops answering an Int that is sometimes an
    errno.
    Twelve raw wrappers answer (Result Int Error) through
    sysResult: the seven filesystem calls (sysWriteFile,
    sysAppendFile, sysUnlink, sysMkdir, sysRmdir, sysRename,
    sysFileSize) and five process calls (sysSpawn, sysWaitPid,
    sysRun, sysRunPath, sysRandomBytes). IO.ax's wrappers
    re-wrap rather than convert — Sys has the errno, IO has the
    path, so the code carries through and only the message is rebuilt.

    sysRun is the one that mattered. Its contract was three answers in
    one Int: an exit code, 128+n for a signal, and a negated errno
    meaning the child never ran. It is split by the type now — Err
    is the spawn failing, Ok carries whatever the child answered —
    which is the distinction Sys.ax's own comment says a driver must
    not lose, "could not start llc" against "llc rejected the
    module"
    .

    Census: Sys.ax 13 → 3, the library 30 → 13. Nine breaks
    declared against 0.3.4; runTool unwraps at the stdlib boundary to
    127 because self_host/ is a different slice.

    The reason this was deferred did not exist. 0.3.3 recorded that
    Sys sits below Err in the dependency order. Err imports only
    Str; Str imports Mem and Vec. There is no cycle, and
    (import Err) in Sys.ax compiles first try. An ordering claim
    about an import graph is checkable in one command.

Added

  • A parameterised type answered through a bare Int is refused.
    Int is this language's universal heap handle and the tree relies on
    it — mkSpan declares Int and answers a Span — which is why
    tyReprClash names only Bool and Float, and why the 2026-08-10
    attempt at the general rule reported 21 of 271 files that were all
    correct.

    A parameterised constructor is different in kind: the handle
    keeps the address and throws the type arguments away, so nothing
    downstream can recover what it holds. Swept 0 over 516 files, and
    probed the other way — Result- and Option-through-Int refused,
    while a monomorphic struct handle, a monomorphic data handle, an
    honest Int and a correctly declared Result are all still
    accepted. tests/diagnostics/498-param-through-int.ax carries the
    defect and that control in one file.

    It closes a class that produced two silent defects during the
    migration itself: IO.makeDir returned a heap address where an
    errno belonged with check printing OK, and driver.runTool did
    the same one layer up and surfaced as AX4003 at run time.

Fixed

  • unwrapOr with a constant is not a port, and it cost three
    defects.
    Wherever a sentinel carried which failure, a fallback
    value destroys it and nothing complains:

    Job.jobSubmit — a failed spawn became pid 0, which is not < 0,
    so the pool counted it live and sysWaitPid 0 waited for any
    child in the process group. 302-job hung rather than failed;
    check printed OK throughout.

    993-filesystem-verbs(== (unwrapOr … 0) -2) is silently false,
    so the case counted one fewer success and exited 1 where its own
    first line says 77.

    305-path-search — printed the fallback for every failure, which is
    the vacuous pass that fixture's own comment exists to prevent.

    All three are match now, and the golden for 305 matches
    unchanged — the assertion survived rather than being re-blessed
    around. The rule, in docs/error-model.md: unwrapOr is safe only
    where the fallback is genuinely equivalent to the error.

  • The stdlib boundary collapsed a Result onto the wrong constant,
    and a missing opt stopped being survivable.
    runTool unwrapped
    a tool that could not be started to 127 — "command not found", the
    shell's convention, and the wrong answer here. Every caller in
    driver.ax separates tool missing from tool ran and failed by
    sign
    : (if (< rc 0) prints "opt not found on PATH; building
    without mid-level optimisation" and continues, while any non-zero
    non-negative rc fails the build. 127 is positive, so CI went red
    on all three platforms. check-driver.sh exists to hold exactly this
    — a PATH with llc and cc but no opt must still build — and it
    did.

    The boundary collapses Err to the negated errno now: the exact
    contract every caller already tests. This was the fourth
    unwrapOr-with-a-constant defect in the migration, and the first
    introduced after the rule against it was written down.

  • An assertion that counted a failure as a success. Auditing all 71
    unwraps rather than waiting for the next gate found
    (== (unwrapOr (sysFileSize f) 0) 0) in 993-filesystem-verbs: the
    expected size is 0 and the fallback is 0, so a failed call and an
    empty file were indistinguishable. A match keeps them apart. The
    other 69 are fire-and-forget cleanup, where the value is discarded,
    or assertions against a non-zero value, where a failure surfaces as a
    failed assertion.

  • The generated API reference documented a contract that no longer
    existed.
    CI caught docs/stdlib-api.md stale on all three
    platforms. Regenerating alone would have been worse than the
    failure: the reference pulls its prose from each source's first
    comment, and those still said "or -errno" — so it would have
    shown the new signature above a description of the contract it
    replaced. Four comments rewritten with it.

Found, not fixed

  • Twenty-nine of the thirty sentinels left are not failures, and two
    of ERR-ADOPT-1's five slices are specified against the wrong
    distinction.
    Classified by what the sentinel means — §5's own
    split between expected failure and programmer error — slice 4's 25
    declarations in self_host/ are 21 absence (namedFieldIndex
    answers "or -1"; structFieldOf answers "0 when the struct does not
    declare the name"; they are lookups, and a lookup that finds nothing
    has not failed) against one genuine failure, already bounded. Of the
    13 left in stdlib/, eight are absence and all five failures are
    already decided — writeStr and the three net calls sit on hot
    paths where an (Ok n) block allocates per call or per poll wake and
    check-net.sh asserts memory ratios on exactly that server.

    So the Result migration is complete. What remains is a
    different one: 29 lookups wanting Option, which is built in and
    needs no import. Recorded as docs/error-model.md §10.1 rather than
    acted on, because renumbering the slices is a decision about that
    document. compat/SENTINELS says why the count stops at 13.