Releases: chrispaig3/Axiom
Release list
Axiom 0.7.3
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
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 + Sync — AxStr, 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...
Axiom 0.6.3
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...
Axiom 0.6.1
0.6.1 — 2026-08-31
-
A program contains only what it uses. A hello world was a
103,592-byte binary holding 388defines, of which a walk from
mainreaches 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 — butemitSymbolTablewrites a
ptrtointfor 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'sptrtointand a thunk are the same token, so a callback
needs no special case, and a wrong root iserror: use of undefined valueout ofopt— a failed build, never a binary that links and
crashes.--emit-staticlibprunes nothing, which is the one named
consumer, so no flag was added.scripts/check-dead-code.shasserts
on the LINKED binary vianm, with a walk deliberately tighter than
the compiler's so it is not agreeing with itself. -
Two soundness holes:
checksaid OK and the program segfaulted.
(:: g (-> Int Box))with body7was 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;checkDeclaredReturncompared by a name list
rather than bytyCompat. 22 type pairs flipped toAX3004, and no
site in the tree was newly refused because the exemption is exactly
this tree'sInt-as-handle convention and nothing more —
tests/selfhost/987-handle-convention-roundtrip.axpins that
exemption at run time, and removing it turns the fixture red.
Separately,emitApplyChainpassed an empty evidence vector, so
over-applied andcast-spine arguments were classified "not a
reference" —462-surplus-closure-arg.axexits 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:
findFnEntanswers 0 for every constructor, andwalkCallHead's
branch added nothing. It was not an oversight —MM-EXEC-9arecorded
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 gainAlloc; 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. -
muton a struct field was parsed and thrown away. A write to a
non-mutfield through an immutable binding compiled clean with no
diagnostic;parseOneFieldsaid so in its own comment. This project
removedlinear,consumeandderivingfor 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 withmemSetWordinstead" 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}.aximport nothing
fromself_host/. The piped surface is byte-identical: 22 sessions,
44 streams, 341,394 bytes before and after, zero differing. -
The language server learns
textDocument/declarationand 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 nofnyet answers one
andnullfor the other.rangeFormattingis 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 teachinggate_initto honourAXIOM_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 388defines, of which a walk from
mainreaches 22:(import IO)pulls inErr,Fmt,Pathand
Systransitively and every function of them was emitted, linked and
shipped. Nothing downstream removed them either —nmon 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 388defines 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.
ButemitSymbolTablealso writesptrtoint (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 definesinternaland
runningopt -O1deleted 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.pruneDeadDefsinself_host/codegen.axnow 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.axloses 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, aptrtointinto 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 iserror: use of undefined valueout ofopt— a failed
build naming the symbol, not a binary that links and then crashes.--emit-staticlibprunes nothing and needs no flag to say so: an
archive exists precisely so another translation unit can call in,
everypub fnis a C symbol by contract, and the whole-program
assumption is false there by construction.axiom_alloc,
axiom_retainandaxiom_releaseare roots in every build, archive
or not, because a--cratehost calls them from Rust where no IR of
ours mentions them.scripts/check-dead-code.shis 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.shhad two floors that expired on this:rows >= 200, calibrated on "a probe importing Sys had 275 on 2026-08-24",
and afound >= 20anti-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:
muton 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 —checkOK, the program
exited 9 — because the parser recognisedmuton a field, skipped
it and recorded nothing — whichparseOneFieldsaid in as many words
at the time, that the marker was "not recorded ... only skipped". That is exactly
the shapeAX2004'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 whylinear,consumeandderivingwere removed from this
language.mutwas enforced instead, because unlike those three it
had a rule worth keeping: a field is immutable unless declared
otherwise, which is the ruleletalready follows.A store...
Axiom 0.6.0
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 ownStr. What is not intended is that on a
case-insensitive filesystem the FILESYSTEM decides the match: a file
namedstr.axorSTR.axsatisfies a lookup forStr, so the same
tree resolves differently on macOS and Linux. Measured 2026-08-31:
withstr.axbeside the entry file,(import IO)fails with
AX3001 undefined variable strLenon macOS and builds on a
case-sensitive filesystem.The second half is the diagnostic. Nothing in that error mentions
thatStrresolved 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 --fullreplays every row of
bootstrap/CHAINfrom the Rust anchorbb730db, 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.checkpointis 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/CHAINon 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 byAXIOM_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--fullre-derives
everything frombb730dbregardless.AXIOM_LINEAGE_FULL=1is
--fullfor a caller that cannot pass an argument. -
Traits and
implare gone from the language. An interface is a
CAPABILITY RECORD now — a parameterised struct holding the functions,
bound withfnand passed as a value — so dispatch is application and
there is no resolution rule to learn.traitandimplare reserved
and drawAX2004with the migration in the message.stdlib/Show.ax's
trait and its fourimplblocks are deleted:showResolvein the
checker rendersString,Bool,Int,Float,Charand every
data/structfrom the argument's static type, and always did.
deriveEq/deriveShoware 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;deriveShowstill generatesshowColorto call deliberately.
compat/BREAKINGdeclares it. Nineteen fixtures went with the
construct, but THREE were pinning properties that outlive it and were
rewritten rather than deleted:010-trait-scopeand
020-trait-duplicateasked whether a global value-namespace occupant
captures a spelling aletor parameter should shadow, and now ask it
of aneffectOPERATION, which is the occupant a program can still
declare (isBuiltinName,isMacroNameandisEffectNameare the
others the resolver asks about).373-shared-default-bindercould 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.
stampPatBinderTykeeps 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 anIntfor a binder that
is really aString— a release of a block the binder still points
into. The only path was a trait DEFAULT body, whichcheckImplComplete
synthesized into everyimplwithout copying the nodes. Measured
2026-08-31 by building the last-write-wins compiler and diffing emitted
IR: 278 fixtures, everystdlib/module andself_host/main.axitself
are BYTE-IDENTICAL. Soscripts/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
andno-foreignshipped in Ada round 1, are enforced, and appeared
only intests/diagnostics/. Tagging one function turned
scripts/check-restrictions.shred 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}.axand
self_host/{core,style,lexer,diag}.ax, chosen becausesymbols --diagnostic-format=aifound 1393 functions tree-wide satisfying both
no-ioandno-allocand these nine files are the ones whose import
graph provably reaches noextern(Ffi.axandrustbind.axare the
only files in the tree with one). The five#effects-incomplete
declarations —vecSortBy,vecSiftDownByand three inHttp.ax, all
indirect-call sites — are deliberately untagged, because a claim there
drawsAX3051rather 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 108AX3049s, 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.allow49 → 288 rows. -
restrict(no-wrap), and it is LEXICAL where three of its siblings
are transitive.+,-and*lower to plainadd/sub/mul
with nonsw, so overflow wraps silently; claimingno-wrapand
writing a raw operator is an act this body performs rather than a fact
the effect row carries, which is the same reasonno-castis lexical.
tests/diagnostics/383-restrict-no-wrap.ax;explaindocuments the
distinction andscripts/check-tools-selfhost.shholds that text to
the compiler's own. -
Removing a keyword broke the direction
reseed.sh's rule does not
mention.scripts/check-seed-provenance.shregenerates the seed from
the commit that last touched the six.llfiles using the CURRENT
compiler; that commit's tree still saidtrait, the current compiler
answersAX2004, and the gate went red — whilecheck-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 andreseed.shdocuments one direction ("the
seed moves when it can no longer compileself_host/"); the other is
that the seed's recorded source must stay buildable BY the tree. Budget
a reseed with any construct removal.bootstrap/CHAINgains a
stage2row and notbridge-needed, socheck-seed-lineage.shstill
replays back to the Rust compiler atbb730db. Verified before
committing: seeds matchSHA256SUMS, the seed built darwin-aarch64
with no Rust, compiledself_host/into stage1, and stage2 and stage3
are byte-identical. -
gate_initresolved the compiler as${AXIOM:-.axiom-bin/axiom}and
never mentionedAXIOM_AXC. Twelve gates that call it without
gate_build_axcsilently measured the installed binary while a caller
believed it was testing theirs. It now resolvesAXIOM→AXIOM_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...
Axiom 0.5.0
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.mdcarried it as a Hazard: "silently
reinterpreted as a custom effect namedIOand reportedmissing IO". Measured — it checks OK, andsymbolsgives 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.
"OnlyIOis declarable" was the wrong word, in four places
(README.md,docs/reference.md,docs/agent-harness.md,
explain AX3042). OnlyIOis required:;@axiom:effect(mut)
over a body that writes a field checks OK, and over one that does not
it isAX3010, an error.Alloc,Mutand every custom effect are
declarable and checked. What is special aboutIOis that its
absence is itself a claim.
The effect census was stale in five places —README.md,
docs/agent-harness.md,explain AX3042and twotypecheck.axhelp
strings all read 3,040 / 1,911 / 1,382 / 299. Recomputed:
3,421 / 2,095 / 1,664 / 332.
The AXSYMKINDtable was missing two of its eight letters.
docs/diagnostics.mdand.claude/skills/axiom-helper/SKILL.mdboth
listedF D C S A T;E(effect declaration) andM(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.axhas 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.shcalledAX3010a 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 describedErras 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/SENTINELSsizes the-errno/-1→Result
migration andscripts/check-compat.shgates 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.netListenforwards__syscall3and 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.axfrom 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, withstdlib/Sys.axalone
going 3 → 26, because almost everynet*/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 instdlib/(the tree writes "Answers"), so its only
0-sentinel arm was dead.
And it counted a constant.sysRandomNumis
(pub fn (sysRandomNum) 33554932)— thegetentropysyscall
number, no bad path — counted because the comment walk climbed a
; ---section banner into prose aboutgetentropytwelve lines
above. That was the whole of the oldSys/Platform.darwin.ax 1, and
docs/error-model.mdhad 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 …)orBool, 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 classifiedfailurewhen it
reaches a syscall andabsenceotherwise, which isERR-REC-3's own
line:Resultis for failure, "not found" is absence and wants
Option. Both numbers are gated per module, which the old file could
not do — it had aResultskip and noOptionskip, so an
in-place port ofstrFindByteto(Option Int)would have left its
count exactly where it was, the regression theResultskip existed to
prevent.
It excludes, mechanically, what the audit had to argue about: a named
constant (fallibleSkipped,intMin, andpollReadFilter, which
is-1because that isEVFILT_READ) — a literal body with no branch
to be the bad path of; a value the caller supplies (symTagFrom
andnetAddrTexteach hold a(- 0 1)that is the seed argument of
a fold, and each answers aString) — 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,
becausepathLastSlashis(pathLastSlashFrom p …)and every-1is
in the helper — but not through a body with a branch of its own, which
is consuming the sentinel (pathExt,mapGet).
docs/error-model.mdclaimed "theResultmigration is complete."
Withdrawn. 29 public functions still hand a caller a negative errno,
26 of them in one module, and none is free:netSocketTcphas 17 call
sites, seven of which never test the result at all — which is the
reason the migration exists, not an argument against it.
AndOptionis not free either, measured over 20,000,000 calls at
--opt 2: a-1return 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
Optionas free and is wrong; the cost is instructions, and
strFindBytehas 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 printedok. 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-0sentinel (vecGet,vecPop,vecLast,
strByte,jsonGet,jsonArrGet); 114 public macros, among them
println, whose value iswriteStr's bytes-or-errnoIntwith 804
expansions in the tree; and members ofpub extern,pub traitand
effectdeclarations. 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...
Axiom 0.3.7
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 aFallibleeffect 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
sentinelfallibleSkipped, whichfallibleIsSkippedreads),
(fallibleDefault d), or(fallibleCounting tally next)around
either, withFallibleTally/fallibleTally/fallibleCount. An
unhandled operation is still exit 71 (ERR-REC-6class ii).
docs/error-model.mdERR-REC-7;tests/stdlib/410-fallible.axpins
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, andcheck-steady-state.shgains a
batchprobe: 2,000,000 records underfallibleSkiphold 1,376 KiB,
the same as 200,000, with akeepingtwin 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 indentedimpl
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 bringingtodointo
scope — appended to an existing(import IO (...))list, else a new
import line. A nested hole is refused rather than guessed at.
IO.todois(-> String a):todo: <what>on standard error and
exit 70 — written withwriteStrandexitrather thandie,
becausedie's effect row is an upper bound the checker will not
accuse a caller on, and atodorouted through it left an untagged
function holding the arm checking clean.tests/diagnostics/363,
364,366,367pin the~>fields; applying the compiler's own
fixes to364in 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'stextDocument/codeActionanswered only what the checker
carried — a help with a fix span — and one assist from what it
inferred. Three more, each in SECTION FIX oflsp.ax, each refused
where its condition does not hold: ImportnamefromMod
(quickfix on AX3001 with a bare name; every directory the resolver's
ownmoduleSearchDirsanswers is searched, a file counted only when
moduleSrcPathresolves 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); Makenamepublic inMod(quickfix on AX3023; a
WorkspaceEdit keyed by the DECLARING file's URI insertingpubon
thefnand its::, becausecheckrefuses either alone —
measured); Extract tolet(refactor.extracton 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).codeActionKindsgains
refactor.extract; drive.py applies each edit and requirescheck
clean or unchanged behaviour.
Changed
- The name map answers from an index, and doubling a module costs
1.9× rather than 3.9×.mangleHasInwas 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%. AMangleIdx— anInternover the
bare names plus a position vector, fed by the five writers and
threaded besidebaresthrough 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.shgains 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 pristinegit archiveof the previous
release fails it at 3.25× / 3.58×.
Axiom 0.3.6
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/fulland
/rangeare 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
ofself_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.mdsays 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.scmnames every bracket-opening rule of the grammar as a
@rainbow.scope— 59 of them, checked againstsrc/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.axfile besidehighlights.scm.
Fixed
-
A macro-generated
struct's fields carried the parser's float flag
where their type belonged.expBuildStructFieldsInsubstituted
wordbof each field — the float flag — as if it were the type and
stored the result back inb, leavingty0. Measured on the tree
before the change: a template spelling(f : Float)madecheck,
buildandsymbolsdie of SIGSEGV in expansion (a dereferenced
1); aFloatarriving through a parameter reached the checker as
a silent wildcard,symbolsprinted#fields=n:{unknown},f:{unknown}
beside the handwritten twin'sn:Int,f:Float, and(* p.f p.f)was
refused with a falseAX3004. The builder reads the type fromty,
recomputes the flag from what the substitution produced (as
expCtorFlagsalready 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
RecTwinrow now carriesRec's own#fields=. -
The formatter reserved thirty-two words the language does not.
axiom fmtrefused(fn (g data) data)whilecheckaccepted it;
docs/reference.mdhas always said a keyword is special only in the
position its rule claims.fpIsReservedWordreproduced the retired
Rust compiler's lexer, and the parity bank had pinned that answer as
070-keyword-in-exprsince the day it was materialized. Measured
over thirty-six shapes: every non-head keyword-spelled identifier was
checkOK (or a checker error) andfmtrefused; in head position
the parser refuses onlyconsumeandbegin(AX2004) — and the
printer PRINTED those two, outputcheckcannot read. Two words
replace the list (fpIsRefusedHead, the twoparseInnerrefuses);
070and131flip to rewrites,190–197pin 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 inf0a2fa3could be renamed
back. -
Every name the emitter invents begins with a byte no identifier can
spell.(fn (f t0) (+ t0 1))passedcheckand died inoptwith
multiple definition of local value named 't0' (AX4003): temporaries
were%tNand a parameter%name, one LLVM namespace, two schemes
that could spell the same string — andt<digits>was only the first;
measured one prefix at a time,label_3,f1,c0,d0,p1and a
lambda parameter named_enveach 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/isIdentCharleave byte 46 out), so the two
namespaces are disjoint by what they are made of; 28 label sites and
17 typed-temporary sites go throughtmpStr/labelStr. Global
symbols are untouched byte for byte (check-symbol-names,
check-freestanding,check-c-abi).tests/selfhost/955-source- names-vs-emitter-names.axnames a parameter after every old prefix:
exit 4 inopton 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 --installcopied the compiler onto the
binary it was replacing.cponto 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/axiompassed
codesign -vand was SIGKILLed — exit 137, no output — on every
exec, so everycheckin 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
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/prepareRenameand
textDocument/renameare 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 twois of two functions
are two names, and aletthat shadows a parameter is a different
binding from it.definitionandhoverask the local half FIRST,
because a local shadows a declaration of the same name: definition from
a read lands on ITS binder through a shadowinglet, and hover on a
parameter answerspw : Intfrom 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:
fnandlambdaparameters 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 thedidOpenon 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/signatureHelpanswers the call the cursor is inside — a
fnof the document, one of its constructors, or an importedfn
with its module named — as(add x y) : (-> Int Int Int)with the
type cut from the::beside thefn, each parameter as the UTF-16
offset pair that slices the label to its name, the paragraph above
the declaration as documentation, andactiveParametercounted from
the bytes by the same string-and-comment-aware scanner every other
request uses.textDocument/inlayHintshows three things the source
does not spell:x:before each argument of a call to a declared or
importedfn(never before a variable spelled like its parameter),
: Intafter each parameter in a header and-> Intafter it, the
last two from the signature's arrow.textDocument/foldingRangefolds
every form and brace block whose opener and closer sit on different
lines, comment runs ascommentand import runs asimports, from a
bracket scan — so a half-typed file still folds.selectionRange
answers word → form → … → document;documentLinkturns every
(import M)the resolver's own search can find into a link over the
dotted name as written;workspace/symbolsearches 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 sdidOpen. 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 samefmtFormatthataxiom fmtruns,[]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 whatfmtwrote to
a copy.textDocument/codeActionoffers 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 intypecheck.axgains a quickfix without a line
changing here — and an Add type signature forfassist for afn
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 thesetthe
diagnostic anchors on, thirteen columns away — with textmut x,
AX3001's to the misspelt call, and the assist toaxiom symbols'
type rewritten; then applies all three in Python, reopens the result
and requires no diagnostics at all.textDocument/typeDefinitionlands
on thedata,structortypeof a signed fn's result, a header
parameter or a constructor, null for builtins.textDocument/codeLens
puts a▶ Runlens onmainand 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 ofrust-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 firstASTNode-to-source printer anywhere
in the compiler —format.axprints from its own token forms,
symbolsrenders 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
mutablelet, a block,set,while,match,cond, a lambda,
struct construction, field access, an escaped string, a char, a float,
a negative literal and a generateddata,typeandstruct,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/fulland/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 answersreferences, expanding nothing — and not by how it
is spelled: a parameter, alet(readonlyunlessmut,
modificationat itsset), 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 inlspFormEnd's
order, so nothing depends on a parse: on the keystroke that breaks
it,(fn (add x y) ...)keepsadda declared function andya
parameter, and a parameter READ in the body falls tovariable,
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 ininitialize; 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, holdsrangeequal tofull's
tokens on its lines, and holds the broken twin's keywords and
literals unchanged. The gate's sweep firesfullandrangewith
every other request. -
**One door for w...
Axiom 0.3.4
0.3.4 — 2026-08-26
Changed
-
The release published the whole changelog as its notes, and the
changelog outgrew GitHub.release.ymlpassedCHANGELOG.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
wantsv0.3.4's notes, not every note since0.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.axstops 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 —Syshas the errno,IOhas the
path, so the code carries through and only the message is rebuilt.sysRunis the one that mattered. Its contract was three answers in
oneInt: an exit code,128+nfor a signal, and a negated errno
meaning the child never ran. It is split by the type now —Err
is the spawn failing,Okcarries whatever the child answered —
which is the distinctionSys.ax's own comment says a driver must
not lose, "could not start llc" against "llc rejected the
module".Census:
Sys.ax13 → 3, the library 30 → 13. Nine breaks
declared against0.3.4;runToolunwraps at the stdlib boundary to
127 becauseself_host/is a different slice.The reason this was deferred did not exist.
0.3.3recorded that
Syssits belowErrin the dependency order.Errimports only
Str;StrimportsMemandVec. There is no cycle, and
(import Err)inSys.axcompiles first try. An ordering claim
about an import graph is checkable in one command.
Added
-
A parameterised type answered through a bare
Intis refused.
Intis this language's universal heap handle and the tree relies on
it —mkSpandeclaresIntand answers aSpan— which is why
tyReprClashnames onlyBoolandFloat, 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- andOption-through-Intrefused,
while a monomorphic struct handle, a monomorphic data handle, an
honestIntand a correctly declaredResultare all still
accepted.tests/diagnostics/498-param-through-int.axcarries the
defect and that control in one file.It closes a class that produced two silent defects during the
migration itself:IO.makeDirreturned a heap address where an
errno belonged withcheckprintingOK, anddriver.runTooldid
the same one layer up and surfaced asAX4003at run time.
Fixed
-
unwrapOrwith 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 pid0, which is not< 0,
so the pool counted it live andsysWaitPid 0waited for any
child in the process group.302-jobhung rather than failed;
checkprintedOKthroughout.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
matchnow, and the golden for305matches
unchanged — the assertion survived rather than being re-blessed
around. The rule, indocs/error-model.md:unwrapOris safe only
where the fallback is genuinely equivalent to the error. -
The stdlib boundary collapsed a
Resultonto the wrong constant,
and a missingoptstopped being survivable.runToolunwrapped
a tool that could not be started to127— "command not found", the
shell's convention, and the wrong answer here. Every caller in
driver.axseparates tool missing from tool ran and failed by
sign:(if (< rc 0)prints "optnot found on PATH; building
without mid-level optimisation" and continues, while any non-zero
non-negativercfails the build.127is positive, so CI went red
on all three platforms.check-driver.shexists to hold exactly this
— aPATHwithllcandccbut nooptmust still build — and it
did.The boundary collapses
Errto 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)in993-filesystem-verbs: the
expected size is0and the fallback is0, so a failed call and an
empty file were indistinguishable. Amatchkeeps 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 caughtdocs/stdlib-api.mdstale 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
ofERR-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 inself_host/are 21 absence (namedFieldIndex
answers "or -1";structFieldOfanswers "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 instdlib/, eight are absence and all five failures are
already decided —writeStrand the threenetcalls sit on hot
paths where an(Ok n)block allocates per call or per poll wake and
check-net.shasserts memory ratios on exactly that server.So the
Resultmigration is complete. What remains is a
different one: 29 lookups wantingOption, which is built in and
needs no import. Recorded asdocs/error-model.md§10.1 rather than
acted on, because renumbering the slices is a decision about that
document.compat/SENTINELSsays why the count stops at 13.