Roadmap
Where cancho is, what got it here, and what is next. The README says
what the language is; this file is the only place that tracks its
history, so the README does not have to grow a paragraph per change.
Tracked in the epic: #1 — milestones with acceptance criteria, sequencing, risks and open decisions.
Milestones
| Milestone | What | Status |
|---|---|---|
| M0 — native hello world (#3) | Lexer, parser, AST, IR, Cranelift backend, a real executable | done — green on linux-x86_64 and darwin-aarch64 |
| M1 — typed core | Type checker, bool, structs, ADTs with exhaustive match, monomorphised generics. No linearity, no effects — deliberately | done |
| M2 — the actual thesis (#2) | Linear ownership, effect rows and capability-passing as one system | done — §3 through §8 of linearity-and-effects.md, every must-reject fixture enforced |
| M3 — minimal but real | Slices and strings, arenas, libc FFI, settled overflow semantics, canonical printer, per-unit identity, file IO through Fs | done — examples/lines.cho is the acceptance criterion: a tool that reads and writes files, counts and filters, and whose authority to do any of it is one narrowed capability |
Everything below M3 is post-milestone work, shipped one slice at a time.
What has landed
One row per slice, newest last. Each links to the document that holds the reasoning — this table is the index, not the argument.
| # | Slice | The claim worth remembering |
|---|---|---|
| #22 | A general heap | Box[T] is res, so §4’s exactly-once rule turns out to be a memory safety rule for free: no leaks, no double frees, no use-after-free, none of them checked by anything new |
| #23 | Reading through a reference | A reference gives references. One rule closed two limits that looked unrelated, and there are no binding modes to infer because the scrutinee decides |
| #24 | Command-line arguments | An effect row is about visibility, not containment. Arguments grant no power, and they are still an effect, because a function that branches on --force should say so in its type |
| #25 | A program in more than one file | The smallest feature with the largest consequence: three design docs had each had to write “there is nowhere to put a library”. It also made canonical-ast.md §1 testable for the first time |
| #26 | Boxed slices | Box[[T]] is a pointer and a length — what every collection wants. Found a double free reachable from ordinary code |
| #27 | Sharing | Rc is not expressible: it needs a value that copies and names an allocation, and every value here is one or the other. Three ways of writing it are three fixtures, refused by three unrelated rules. Gen is a library, and is examples/slab/ |
| #28 | Tuples | The first feature a library asked for rather than a design — and it costs nothing: a tuple and the struct it replaces emit byte-identical objects |
| #29 | Shadowing | The restriction was a real rule stated too bluntly. A binding may be shadowed exactly when it is dead — which is the rule assignment already had, so it is one rule with two syntaxes |
| #30 | Standard input | Not a seventh capability: a second label on Io, following Fs’s fs_read/fs_write. Which renamed putchar’s effect to io_write across the repository |
| #31 | Modules | A module reaches no hash. A call has encoded the callee’s hash rather than its spelling since M0, so namespaces cost the identity system nothing — and a module is not a trust boundary: pub means reachable, never safe |
| #32 | A standard library | --std, with the source compiled into the binary rather than looked up. Found the compiler emitting every non-generic function rather than what main reaches — 6720 bytes against 1048 for a program calling none of it |
| #33 | Mode polymorphism | Checking a claim found a leak and a double free: a val on a generic declaration was trusted rather than checked. And a worse bug — a call took its types from one function and its effect row from another, so a call into C could declare [] and compile |
| #34 | Collections | Which collections hold a resource is decided by shape, not generics: a list works because taking it apart produces its elements; an array does not because freeing one is a single free that runs nothing |
| #35 | The README | Not a language change: the README had grown a paragraph per slice in three places at once, so the history moved here and the per-example prose moved to examples/ |
| #36 | A borrowed field | The double free was never about reading, it was about owning. A res field through a reference is a borrow, and the refusal that used to guard it was doing work the type rule already does |
| #37 | Slicing | s[a..b], and the answer to “a writer abstraction”: there should not be one, because a function taking a Writer must declare the union of what every destination could do, and a union row is not an exact row. The abstraction is the buffer |
| #38 | defer | Sugar that stays sugar: expanded during lowering, so the checker replays the same events the hand-written version would, and there is one set of linearity rules rather than two. §12’s question answered — “visible” here means the type says what happened, not “written on the line where it runs” |
| #39 | The authority surface | §12’s release question answered no: the four release calls are not ceremony around the authority declaration, they are it — main owns rather than borrows, so its row is [] and every entry point has the same signature. cancho authority reads them instead |
| #40 | [budget] | Answered no, from the units: a budget is wall-clock seconds, commands and cents, none of which is a property of a program’s text. lex-os already charges it, at the boundary that can stop you. What was wanted was legibility, so the authority report grew --output json |
| #41 | What a program can reach | Answered by building it: a REST endpoint over a real TCP socket, with no socket type, no Net capability and no library — because sockets are libc and libc has a name. The no’s are one sentence: a foreign result is a scalar, so every opaque handle (TLS, libpq, FILE *) is out. And the row cannot say net, because a library is not an authority domain |
| #42 | What the overflow trap costs | The README’s “low single-digit percent” had never been measured. Measured: +2.8% call-bound, +3.6% memory-bound, −9.1% branch-bound, +40.5% arithmetic-bound — so not a percentage but a rule. And the stated reason was wrong: not the never-taken branch, but that a trap is observable, so the loop cannot vectorise — clang pays 46% and gcc 74% for the same guarantee. Both documents corrected in place |
| #43 | Bits, and a port | The first program here that already existed: coreutils base64, byte-for-byte on both directions. It needed the bit operators and hex literals and nothing else — no capability, no library, no change to linearity or rows. And running the suite under load for an unrelated reason found a real bug in examples/serve/: one read returns what arrived, not what was sent |
| #44 | A port with resources | LC_ALL=C sort, five owned resources on the heap, checked against GNU. Answered §6’s four untested things: the move loop costs three tokens rather than difficulty, effects concentrate at the edges (four of eight rows are []), and borrow mut never got in the way — five nested blocks did. Found four missing library functions, every one absent because nothing had asked |
| #45 | Against C and Rust | The measurement overflow-cost.md §4 said was owed: 1.6× at equal semantics, on both a compute-bound and a memory-bound kernel, with Rust within 4% of C — so the gap is the backend, not ownership. And the numerical row: f64 is 17% slower than the fixed point cancho is forced into, so the missing float costs precision (10 orders of magnitude) rather than speed |
| #46 | What a checked row is worth | The answer to is there anything it does better? — yes, exactly one thing: the row is a checked purity proof, which C can only promise unchecked and Rust cannot state. 35% of functions here qualify; worth 1.94× as CSE and 158× with hoisting. Collected by nothing: Cranelift has no call attribute, and an own optimiser is a non-goal — now an informed one |
| #48 | Floating point | float, IEEE-754 binary64 in full. The interesting decision is §2.1: NaN and infinity do not trap, because wrapping lies about a value where NaN announces the absence of one — and a per-operation trap would defeat exactly the loops floats are for. truncate traps on what C leaves undefined. Writing it found a real collision the design had waved away: t.0.1 |
| #49 | Printing a float | The shortest decimal that reads back to the same bits, and it is library code: std/fmt.cho is written in cancho, row [], no Heap, and the compiler’s whole contribution is bits_of — one bitcast. std.bignum underneath has no division, because the one quotient a printer needs is a single digit and nine subtractions settle it. Checked against Rust’s {:e} over 9000 values, which found two real defects an example never would; §3.4 records the three where Rust and Python disagree and why both are right. A comment claiming 2200 bits was measured at 1080 and corrected |
| #50 | Compile-time evaluation | The answer to can it be faster than C? began with finding we were slower at the easiest thing there is: 2 + 3 * 4 - 14 is zero and emitted fifteen instructions, because a checked add is sadd_overflow + trapnz and Cranelift’s folding rules are written for the plain form — overflow-cost.md §3.2’s mechanism sighted a second time. Folding it in the front end brings that to parity; a certain trap is now a compile error rather than a SIGILL. Past C in one place only: clang gives up on recursion, so fib(23) is a runtime call at -O2 and a constant here. And the estimate that motivated the work was wrong — 65 foldable calls predicted, 15 actual, because pure-and-constant is necessary and not sufficient |
| #51 | Compile-time data | A static item: a body with no parameters and no run time, evaluated during compilation into read-only data. examples/base64/ drops its 64-entry per-character scan and decodes 6.1× faster. But the measurement corrected the pitch: compile-time.md §8 called this “the one that pays” and 5.7× of the 6.1× was already writable in 36 lines of plumbing. What the feature actually buys is that plumbing, read-only pages, and the row that is a capability argument — 65 536 entries does not fit in a 64 KiB arena and traps, so a program that released heap could not have one at all |
| #52 | Layout | The first slice here to build almost nothing, on purpose. This roadmap said packing reaches C and transposing goes past it; measurement says the second half is wrong. Transposing is worth 1.43× in cancho and 1.31× in C — the same transform, the same size, so it moves both along rather than closing a gap, and the vectorised half of the prize needs a vectoriser overflow-cost.md §3.2 already showed we do not have. Packing is real (2.6× on a byte-heavy struct) and 3 of 83 struct fields here are byte or bool — and slab’s Entry would not shrink at all. So: cancho layout, which makes the deferral falsifiable instead of remembered |
| #53 | Against a wider set | Three programs from the Computer Language Benchmarks Game — fannkuch-redux, spectral-norm, binary-trees — each checked against the answer the Game publishes. The 1.6× headline was two kernels’ midpoint; across five it is 1.17× to 2.58×, and it tracks how much of the run is in code Cranelift generated. Porting found a compiler crash: two sibling region blocks put the backend’s arena stack at length 0 where index 1 was wanted, latent since arenas landed because nothing here had two regions side by side. And scripts/game.py reports the spread, which no published ratio here had carried |
| #54 | Bulk output | benchmarks-game.md §2.1 parked this as “a real finding and a separate slice”, and the finding is not the 12.8×. examples/serve/ had been writing whole slices for four slices, through Ffi("libc") — which reach.md §5 says is every authority at once. So a program that wanted to print quickly had to ask for the filesystem, the network and exec, and a program that asked only for Io was held to one byte per call: the cheap thing to grant was the expensive thing to run, which is the wrong lesson for a capability language to teach every time someone profiles. Fixed in the primitive rather than the library, because a function holding &!i Io can only call what Io authorises. It lowers to fwrite and not POSIX write, since putchar is buffered and a raw descriptor write would interleave wrongly. And §4 declines the headline: base64 gains 1.6×, not 12.8×, because 6 ms of the saving goes straight back into byte-at-a-time buffer fills that cc -O2 vectorises away. §4.1 then corrects §4 itself (#55): it had said a program “whose time is output” gets most of the 12.8×, and examples/sort/ — which writes 9 MB and moved onto the bulk path with no change to the program at all — gains 1.22×. Volume written is not time spent writing, and the gain is only ever the share of the runtime that was call overhead |
| #55 | Canonical identity, made observable | Asked what it would take to put cancho code in lex-lang’s lex-vcs, and the answer was that 81% of that crate is already language-agnostic — it keys on String ids and BTreeSet<String> effects — while cancho’s own hashes are the part that is not ready. So this slice builds the falsifier rather than the integration. cancho-id had 61 tests and all of them were relational: they compare two hashes to each other, and not one said what a hash is, which left §8’s “not frozen across releases” a claim nobody could observe. 35 golden fixtures now pin it, reporting by fixture name so an insertion does not cry wolf — and the first thing they caught was one of my own fixtures: perturbing the BINARY tag moved unary, because 0 - a is a subtraction and there is no unary minus, so that fixture had never covered UNARY at all. And §8’s field-order entry turned out wrong in both halves: a struct literal was never a gap, because the checker refuses P { y: 2, x: 1 } rather than reordering it (defined-behaviour.md §3 — the order you read is the order it runs); the destructuring pattern’s gap was real but the stated reason was not, since sorting by name settles it with no declaration lookup and also survives a struct reordering its own fields. The binders sort with the names, because they are positional from there on and a collision would be worse than the difference removed |
| #56 | UTF-8 | Asked how far cancho is from a string type, and the answer was already written: strings.md settled it as &r [byte] with no encoding claimed. So the question became the library, and the probe says the language needs nothing — a forty-line decoder matched GNU wc -m exactly on the first compile (119 bytes, 79 code points over ASCII, Latin, Japanese, emoji and a combining sequence), and vec.Vec[&t [byte]] compiles, so split can return substrings bound to the text they came from. What the probe did find is the decision strings.md §1 said would have to be paid somewhere: §1 declined a validated string because it would have to say what an invalid one is — an error, a replacement character, an unrepresentable state — and a decoder cannot decline. Seven malformed fixtures, four implementations, four different answers, and GNU contradicts itself by counting f5 80 80 80 as one character though it encodes a value above U+10FFFF. So GNU is not the oracle here, which also means wordcount.cho must never be checked against wc -m. §3 takes strict validity and the Unicode maximal-subpart skip rule, verified to reproduce Python’s replacement counts on every fixture — and std.utf8 now implements it, agreeing with Rust’s own from_utf8_lossy and str::from_utf8 over 2,000 generated cases. Nothing in the design moved on contact with the code, which is the first slice here where that is true |
| #57 | The rest of the string library | utf8.md §1 said split, trim, join, ordering and case are “code, not design”. Rather than invent the surface, examples/cut/ was ported to ask for it — the route vec.set and vec.swap took — and it asked for exactly two: bytes.count_byte and bytes.field. trim came with the field parser and bytes.compare came from examples/sort/, which had the rule inline. Four functions, three call sites, checked against GNU cut on eight field specs. Porting it found the 64 KiB arena as a line-length limit: a 65 000-byte line buffer beside a 1 025-byte bitmap traps on exhaustion, so the number in the source is what fits rather than what was wanted, and the comment says so. And it found standard-library.md §5.3 claiming a fix it had not made — wordcount.cho moved its word boundary to std.bytes, tally.cho kept a byte-identical copy, so “one definition, in one place” was two definitions that happened to agree with nothing checking. Corrected in place, in the entry that named the failure mode |
| #58 | Standard error | Two documents cited “reach.md §6’s standard-error gap” as a blocker and reach.md never named it — five rows, none of them this, and the second citation copied from the first. What the absence cost, measured: every failing program here produced zero bytes on every stream, against GNU’s 64, 68 and 22 for the same three failures, and eleven of nineteen examples have a non-zero exit. The workaround is worse than silence twice over — a diagnostic on the output stream fed a line of prose to the sort downstream of it (wc -l answers 1 where GNU answers 0), and standard output is fully buffered when it is not a terminal, so a message written just before a trap is lost: 0 bytes to a file and 0 through a pipe. Closed as a third label on one capability — standard-input.md §2’s rule a third time — with err_write named for the stream because 1> and 2> are two redirections, and §2.1 states plainly what that widens and why the answer is that the grant is the capability while the gate is the row. Five diagnostics across sort, cut and base64, four of them in the suite against the system’s own GNU tool. And §1.3 corrects itself: the first draft said the failing path was untested, and it was tested — against the status this program happened to have, while the same test compared eight valid specs to the real /usr/bin/cut, because a silent program gives a test nothing to compare. cut exited 2 where GNU exits 1, under a comment claiming it used GNU’s |
| #59 | Refusals a machine can read | lex-lang’s AGENT_GUIDELINES.md is a contract with a reader that is a program; cancho had none. Measured: across 196 must-reject fixtures the checker produced 125 distinct message shapes, 101 of them exactly once, reported one error per invocation, and offered no machine-readable form — so classifying a refusal meant matching 125 English patterns by regex, one compile at a time. Closed with a 52-rule catalogue: a stable rule tag on every Diagnostic, set at the error site rather than derived from the sentence, plus check --output json and every independent refusal reported rather than the first (checking is already total and per-declaration, so those bodies are independent; a parse error still ends the file). §2 is the part worth reading: the rule_tag half of lex-lang’s answer transfers whole, and suggested_transform does not — its own guidelines page shows it beside rule_tag in the checker’s JSON, and in the source it lives in lex-store/lex-vcs/lex-lsp as an attestation against an op id, which is machinery cancho deliberately does not have. A fix field was designed here and cut, because the argument for it is about a consumer nobody has watched. And §3.2 is the finding: the conformance harness has claimed since M0 that “adding a rule means adding a fixture”, which was unfalsifiable while nobody had enumerated the rules — it was 43 of 52, and every_rule_has_a_fixture now keeps it |
| #60 | AGENTS.md | agent-errors.md §7’s own next row. lex-lang’s contract says a downstream repo copies AGENT_GUIDELINES.md as AGENTS.md; cancho is a different language with different rules, so it needs its own — and it had none. Measured: 90,283 words across 42 documents and no first page, so an agent asked to write cancho could read all of it or guess. Written from evidence rather than taste — the rules are ranked by how many fixtures they have (linear-value-unconsumed 19, effect-not-declared 12, reference-escapes-region 11, mode-bound-violated 11), and §6’s six facts are the ones that each cost this repository a compile or a SIGILL while porting a real program. cancho agent-guidelines prints it from inside the binary, the way --std carries the library. Every checked code block is a fixture: the valid ones compile, the refused ones are refused with the rule they name, and changing one rule name to a wrong one turns the suite red — checked by doing it |
| #61 | A line reader, answered no | The roadmap asked for std.lines because “two programs hand-roll the same loop”. Reading them says one does: tally.cho keeps nothing, wordcount.cho keeps nothing, base64.cho buffers its output, sort.cho keeps the whole input — only cut.cho reads a line at a time. A documented no, and the useful part is what the row was pointing at. examples/cut/ read a line into a 60,000-byte arena slice and the source called that “truncated”; it is worse, because a truncated line loses its delimiters, so cut switched to the no-delimiter rule and printed 60,001 bytes of the wrong field with exit 0 — a silently wrong answer, in a program checked against GNU on eight field specs, none of which varied the one dimension that mattered. Fixed by growing on the heap: 1.18× for correctness (152.9 → 181.1 ms on 14.7 MB), and the program’s authority report gains heap, which is bulk-io.md §3.2’s rule meeting its mirror and not contradicting it. One function was earned — buffer.clear, because reusing the allocation is 13.0× faster than drop plus empty per line, so it is a gap rather than a nicety |
| #61 | GPU, and whether lex-gpu should exist | Asked whether cancho could target a GPU, how a native one would work, and whether the GPU dialect should be its own language. The first two are design; the third looked like taste until the numbers came in. Same reduction, six ways, SIMD instructions counted in the emitted code rather than inferred from the clock: a bounds check is free (1.01×, SIMD unmoved at 10) and an overflow trap is not (1.46×, SIMD 10 → 0). So the memory half of the no-undefined-behaviour claim survives a GPU at no cost and the arithmetic half cannot — a much smaller concession than it first looked. And the one that settles the language question: deleting the trap does not reach C. cancho without traps is still scalar, 2.27× off vectorised clang and 1.55× off scalar clang, so the trap is 1.83× of its own cost and everything left is Cranelift — which means LLVM is what buys GPU speed, equally, whichever language the kernels are in. §2.1 corrects overflow-cost.md §3.2 in place: it measured one guard and generalised to “the check” |
| #62 | sqrt, and the capability question | floating-point.md §7 had carried “std.math over floats — the capability question has to be settled first” since float landed. The capability question turns out to be the wrong question: sqrt is one instruction (sqrtsd, fsqrt), so it reaches no library, needs no Ffi, and its row is [] — arithmetic does not acquire a capability after all. What decided it was measuring the two programs that hand-rolled a square root, which is how the bar was met (newton.cho and benches/game/spectral.cho, counted by reading them). Both are wrong: spectral.cho’s twenty-step Newton loop is not correctly rounded on 58.4% of 40,008 values and is wrong by 143 orders of magnitude on a large one — 10^300 comes back as 4.77 × 10^293, because the first guess is x/2 and twenty halvings do not cross 143 decades. The benchmark never noticed: it only ever asks for the root of something near 1.27, which is cut’s long line again — the dimension the test never varied. So this is a builtin rather than library code, which is the opposite of the call float-printing.md made, for the same reason: put it where it can be correct. A correctly-rounded root is not expressible in cancho; the shortest decimal is. sin, exp and log stay open, and float abs/min/max are refused on §1’s rule — one asker with a working alternative. And AGENTS.md §6 was wrong: it said there is no unary minus, and there is; the table that said so was the one part of that document the suite did not check, which it now does |
| #63 | &! is not unique, and cannot cheaply become so | Answers no to the question README.md asks two sections after its own table had settled it. Across the 82 programs this repository builds, making &! mean Rust’s &mut refuses one fixture — written to document the behaviour being changed — so the stated cost, “it refuses programs that compile today”, was wrong, and the real price is elsewhere. There are three ways to alias a &!; two close syntactically and the third, a reference returned from a call, needs provenance in signatures, which is lifetimes and the borrow checker the non-goals exclude by name. And the fact would have no consumer even if proved: Cranelift has no noalias, only three fixed WebAssembly alias regions. §6 is the two conditions that would reopen it |
| #63 | Every check the backend emits, priced | Shipped in the same PR, and it corrects two documents at once: overflow-cost.md §3.2 said “the check” having measured one, gpu.md §2.1 narrowed it to the overflow check having measured two, and six of eight loop-body checks take the SIMD count to zero. The axis is neither memory safety nor arithmetic — s[lo..hi] and s[i] are both bounds checks and cost 2.16× and 1.00×, and the same two comparisons on the induction variable cost 0.99×. Provable is free, and loaded is not. The worst check in the language is truncate(f) at 3.35×, which nothing had looked at |
| #64 | Poison, and where the thesis lives | Whether a per-lane flag reduced at a boundary costs less than a trap, which gpu.md §5.1 had left as one number. It is three. On a wide vector ISA poison is a large win for every per-element check — a << b and -x become free and truncate(f) drops from 3.28× to 1.33×. On the overflow check carried by a reduction it does not help and is worse, because four lanes compute four different partial sums, so the flag records a different question. A control kernel falsified the first explanation offered for that: the barrier is the spelling, since the same test written as sign logic vectorises. And on baseline SSE2 the whole win disappears, so the answer is a property of the target rather than of the semantics |
| #65 | File handles, and three names that cost more than the design | open_read, file_read, file_close, a res File and a three-constructor Read, so the end of a file and a failure stop being two meanings of -1. The milestone was smaller than filesystem.md §3 advertised — two of its three questions were already answered by machinery that exists, and a forgotten file_close is a compile error with no new rule. Building it found three things the design had not: Result[File, int] cannot be a builtin’s signature (Result is std, and std is opt-in), the label carries no path because the path is spent at open_read, and three new prelude names cost 31 fixtures plus the extern fn read in examples/serve/ |
| #66 | sort through a handle: the I/O went, the time did not | The port that tests the previous row. Reading through a handle takes 1.00× the I/O the old path took 2.75× of, and the wall clock moves 0.932× — three times less I/O bought 7%, which is bulk-io.md §4.1’s correction arriving from the reading side. What handles are actually worth is the code they delete: an 8 MiB ceiling and the exit status that existed only to report it |
| #67 | The backend, audited rather than remembered | Five documents gate the roadmap’s largest row on four claims about Cranelift, and only one had ever been read from the source. All four hold, checked against cranelift-codegen 0.121.2. The fifth fact is why it was worth writing down: is_call() and can_trap() are adjacent clauses of one four-line predicate, so this project’s checked purity proof (worth 158×) and its defined behaviour (costing 3.35×) are blocked by the same function. Also: sadd_overflow is pure — it is the trapnz beside it that pins the loop — and Cranelift’s SIMD is an input language for WebAssembly, with no pass that makes vectors out of scalar code |
| #68 | Effect polymorphism, answered no | The roadmap’s last language row, and the only one with no document behind it. 537 functions here write a row and not one could be polymorphic in it — not does not need to be, could not. A row is fixed at the declaration and read straight off it by every caller, and the two things a function is generic over, types and regions, cannot select a callee. The nearest asker, io.write_all against io.error_all, differs by the builtin as well as the row, so collapsing it needs a function value: row polymorphism is the shadow of higher-order code, which Rule::NoFunctionValues refuses with a fixture |
| #69 | The hash rate, measured | The number canonical-ast.md §8 said nothing was measuring, and the one the lex-vcs row below was gated on. It is two numbers and they disagree. The encoder moved 20 times and the golden fixtures observed none of them, because they landed after the movement stopped — the instrument has never been exercised. The language moved enough that 71% of this repository’s own 117 .cho revisions no longer type-check under today’s build, and a single label rename (io → io_read/io_write) is 42% of that. A hash-keyed VCS inherits the second rate, so what to wait for is a plateau, not a number |
| #70 | A character, written as a character | 'a' is the integer 97: a third spelling, after decimal and hexadecimal, so no new node kind and no moved hash. 121 places in this repository spelled an ASCII character as a decimal number, and three of them already carried the translation in a comment — the clearest evidence a notation is missing, since the information exists, a human typed it, and nothing checks it against the number beside it. Counting decided the type rather than taste: 91 of the 121 sites want an int, because putchar and getchar mirror libc, and the commonest site is 48 + n % 10, seventy-five times. So it is a spelling, not a type — bitwise.md §1.1’s rule applied a second time — and §2.1 states the half it does not buy: the 30 byte sites keep their byte_of and gain only a readable argument. Two sites keep their numbers, the vertical tab and the form feed, because the escape set is strings.md §4’s and neither has one. This row also fixes the table above: six slices had landed without one, and two rows linked to the wrong pull request, because a PR carrying two slices was given two consecutive numbers |
| #71 | The checks, read out of the binary | check-cost.md is subtitled “what every check this language emits costs” and it measured C: each kernel in benches/guards.c carries a comment naming the cancho check it stands for, and not one had been read against the machine code. Six of the eight proxies are exact — the author knew the unsigned trick and used it. The two that are not include the headline: the C guard tests the float range with two ucomisd on every element where Cranelift emits cvttsd2si and one cmp $0x1/jno, because the conversion answers a sentinel that rax - 1 overflows on — so 3.35× prices a guard this language does not emit, and the worst check in the language does not survive as a ranking. Division is backwards twice: §5 said both operators emit nothing, and each emits exactly one comparison, not the same one — 1 / 0 dies with SIGILL from its own ud2 and 1 % 0 with SIGFPE from the hardware. int::MIN % -1 answers 0, where defined-behaviour.md §2.3 said it traps, borrowing the quotient’s reason for an operator that produces no quotient — the compiler was right and the rule was wrong. And the constant folder disagreed with the backend about exactly that, so one expression was a compile error written down and a 0 computed. Writing the probe also found a codegen bug: a folded byte-returning call put an int-shaped literal where the backend expects one machine byte, and int_of(g(65)), g(65) == byte_of(66) and returning it each failed the Cranelift verifier with no span and no rule tag. Five documents also had the check’s name wrong — it is truncate(f); int_of widens a byte and emits no check at all |
| #72 | Flags | This list carried “flag parsing” under ordinary work, blocked by nothing on the assumption that the two programs which parse flags would each be a little shorter with a library. They are not too long. They are wrong: of the twelve spellings GNU accepts across the two ports, six disagreed. cut refused four of them loudly — -d ,, -d, -f 2, --delimiter=, and a trailing --. base64 answered --decode and -di by encoding its input and exiting 0, because --decode is not two bytes so the test for -d was false and the flag was dropped. And every base64 test in the suite passed exactly -d: twelve input sizes, both directions and three malformed inputs, all through one spelling of one flag, which is line-reading.md §1’s shape again with the spelling of an argument in place of the length of a line. std.flags is a cursor, not a getopt_long table, because a table has to know which options take values and then resolve GNU’s abbreviations against itself; here the program says when it wants a value, so there is nothing to search and nothing to disambiguate. Three functions — start, step, value — and the nine shapes of an argument. Both ports now agree with the real coreutils binaries on every spelling but the three §4 keeps on purpose, and those are asserted to still diverge so one that quietly starts agreeing gets moved rather than forgotten |
| #72 | The first page | agent-errors.md §1 measured what the machine audience had and AGENTS.md answered it. This is the third audience — a person deciding whether the project is worth their attention — and the instrument arrived on its own: a reader outside the project wrote a fifteen-proposal design document, of which eight were already shipped with a document behind each. Its phases 1 through 4 are done and it opens at phase 1. A proposal that re-proposes eight shipped features is a measurement of the first page, and a better one than any this project could have invented, because nobody wrote it to be one. The cause is countable rather than aesthetic: in 572 lines the words sandbox, audit and evidence appeared zero times, agent in the execution sense zero (both hits were about AGENTS.md, the writing guide), and lex-os once, inside a parenthesis about narrowing — so the three proposals that live in the runtime could not have been known about, and all three were re-proposed. The shape said something else again: “Performance expectation” was 180 lines, 31% of the file, the largest section by far and about the weakest thing here, while “What exists” was one 70-line block of prose. The rewrite adds an ecosystem map that did not exist — three repositories, one idea, two of them wired together, and cancho sharing no code with either, which is the integration the proposal assumed and this page now denies in writing. Plus the two joins as rows: lex-vcs, gated on a plateau, and .cho under a lex-os grant, not built. Nothing about the language changed. Two tests keep it honest: every local documentation link resolves — run by hand at the end of every slice until now — and the README’s surface block matches --help, which caught layout and agent-guidelines missing from the page and check having lost its --output json |
| #73 | Under a grant | README.md and authority.md both said authority --output json existed “for a supervisor checking it against a grant”, and no supervisor had ever been asked. Measured against lex-os’s real grant — {filesystem, network, exec} plus an egress allowlist — one dimension is enforceable and more precisely than the grant can express, because fs_read(p) carries a path where the grant has a tri-state. Two are not enforceable at all: examples/serve/ binds a TCP port, listens, accepts and answers HTTP, and its report is args and ffi. reach.md §5 argues correctly that the row is not wrong; what is new is that against network: None it is unable to refuse, and the only program here that touches the network is the one it cannot refuse. §3 removes the rescue and corrects reach.md §5.2, which said the foreign symbol list tells a supervisor what it is running “without trusting a word the program says about itself”: the list is a proof about names and a heuristic about domains, because the step from a name to a domain is the declaration and the declaration is the program’s to write — six lines of extern fn syscall type-check, open a socket and report ["syscall"]. §4 is the diagnosis in one sentence: Ffi(lib) is the one capability whose label does not bound what it authorises, and the filesystem dimension works precisely because filesystem.md §2 already took files out of libc. So reach.md §6’s Net(host) row is promoted: not a refinement of the report but the prerequisite for cancho code running under a grant at all. And the join itself is smaller than it looked — no compiler integration, no embedding the front end; --output json is already the interface |
| #74 | The network | The design for the row the previous slice promoted, written before the code the way filesystem.md was — and the probe found that reach.md §5.1’s framing describes neither side. “A host is a thing worth narrowing to” is the outbound question. examples/serve/, the only network program in this repository, declares socket, setsockopt, bind, listen, accept, read, write, close — pure ingress, and no connect anywhere in the corpus. Read the other side: a lex-os grant’s network fields are network and egress, and both are outbound; there is no inbound notion in it at all. So the one program cancho has does the direction the grant does not describe, and the grant describes a direction cancho has no program for. §2 argues the asymmetry is correct: outbound is the program’s choice — it picked the host, so the authority question belongs in the type — and inbound is not, because who reaches a listening port is decided by routing and never by the program, which is exactly why lex-os’s second wall is iptables on the tap device rather than a grant field. Net therefore has two axes: net_out(host:port), narrowing by prefix the way Fs does, and net_in(port) bounding a port and nothing more. §3 keeps filesystem.md §2’s decision — the operations are builtins, because leaving them extern fn gates them by Ffi("libc") and Net contributes nothing. And §5 is the uncomfortable count this project’s own rule demands: inbound 1 asker, outbound 0. The half that unblocks the lex-os join is the half nothing here asks for, so this ends at settled, not built, and the next step is a program that connects rather than an implementation of a design no program has tested |
| #75 | Fail closed, and credit | An outside audit’s two cheapest correct items: the authority report now leads with "bounded": false for any program reaching foreign code, so a supervisor that reads nothing else refuses by default (under-a-grant.md §5.1); and a related-work document crediting what this project took and who got there first — Austral above all, which the repository had never mentioned |
| #76 | The folder against the backend | The audit’s C2(b), and the first item of its recommended order. fold.rs said “bin_traps_exactly_where_the_backend_does checks them against a running program” — a test that never existed, while the one disagreement it would have caught (int::MIN % -1, #71) shipped. Now every operator the folder evaluates, on every pair of 14 boundary operands, is computed three ways — folded during lowering, folded through a call, and at run time on operands neither optimiser can see: 5,156 cases, 0 disagreements, the same 436 traps on both sides. Five deliberately broken folders were each caught, #71’s bug in one case. Writing it found the fault it was not built for: bits_of(0.0 / 0.0) was 0xfff8… on x86-64 and 0x7ff8… on aarch64, so compile-time.md §6’s “the same value on every host and every target” was false for one builtin. bits_of now gives every NaN one pattern — two instructions, no branch — and since it is the only operation that can see a NaN’s bits, the sentence is true again, corrected in place |
| #77 | A program that connects | The second item of the audit’s order, and the step net.md §5 named instead of building Net. examples/fetch/ is an HTTP/1.0 client, and the suite points it at examples/serve/, so a cancho client fetches from a cancho server. It found four things. No names: getaddrinfo answers a pointer, so a program only ever connects to an address while a lex-os grant only ever names hosts. The destination is data, so net.md §4’s literal is a bound checked at run time, as Fs(prefix) is, corrected in place. struct sockaddr_in is 2, 0 on Linux and 16, 2 on macOS, and both network programs work on macOS only because BSD reads family 0 as AF_INET. The first version said connect would refuse the Linux bytes, reasoned from memory, and the darwin runner refuted it. errno is a pointer, so why a connection failed is unknowable. It found no need for a socket type. Outbound askers: 1, so Net stays settled, not built. The test written to fail on this day is now a count |
| #78 | When the compiler is wrong | The audit’s C2(c). Measured by putting #71’s bug back for one build: check answered { "refused": [] } with exit 0, and build printed an unlocated “code generation failed” with exit 3, the code the CLI documents as the environment failed. So a compiler bug read as the machine’s fault, and the machine-readable gate passed a program that would not build. Codegen’s 22 invariant sites were bare Rust panics, exit 101. Now a backend failure is a refusal with rule internal, located at the function whose code failed, exit 1. Each function’s code generation runs under catch_unwind, and check runs the backend too, at about 2× its old time, a few milliseconds per real program. Tested without a bug to test on, by breaking one function’s IR by hand |
| #79 | Files, and a budget for them | The audit’s C1. cancho-ir/src/lib.rs had reached 10,060 lines, one slice at a time, with nothing to say no. It is now a table of contents over ir, builtin, defs, function and lower/, where impl FnLowering is split across child modules that see its private fields, and nine unit-test files declared with #[path] so no test changes name. A pure move: the same 228 test names before and after, and every line of the old file present in the new ones apart from formatting. The largest file is now 1,594 lines. tests/files.rs refuses any source file over 2,000 lines (rustc’s tidy uses 3,000). The two files already over it, codegen (2,886) and conformance.rs (6,684), are held to those numbers as ceilings that may only come down. CONTRIBUTING.md writes down the gate, how a slice is done and the conventions |
| #80 | Files, and a budget for them | #79’s two ceilings, retired. cancho-codegen (2,886 lines) becomes abi, emit, layout and body/, with impl BodyEmitter split across child modules the way FnLowering was. Its largest file is now 688. The conformance suite (6,684) becomes tests/conformance/main.rs and seventeen topic modules, still one binary. Its item boundaries were found with a scanner that tracks strings and comments, because embedded cancho sources put fn main at column 0. Both are pure moves: the same test names, 8 and 104, and no line of either old file missing. Two codegen doc comments were already wrong and are corrected: one still said every error there is the environment’s, which #78 made false. CEILINGS is empty. The files nearest the line are parser.rs (1,997) and cancho-id (1,909) |
| #81 | Fuzzing the compiler | The audit’s C2(a). tests/fuzz.rs is a stable-Rust mutation fuzzer, part of cargo test: it seeds from all 331 .cho files, makes one to three token edits (half of them replacing a token with one of the same kind, so a mutant is usually a different program rather than a broken one), and requires three things of every mutant: nothing panics, what parses prints back to the same tree, and what the checker accepts the backend compiles. 44% of mutants parse, 9% check and 8% compile, and the test asserts floors so a shallow run cannot pass. Over 450,000 distinct mutants the checker and backend found nothing. The printer failed four ways: (e,) printed as (e), turning a refused program into an accepted one; a label argument such as ffi("a\n") printed unescaped and did not reparse; since #43, prefix and postfix operators bound no tighter than *, so -(a / 10) printed as -a / 10 (UNARY and POSTFIX are now derived from the operator table); and 1 . 2, component 2 of an integer, printed as the float 1.2, a fixed point only the tree comparison could see. tuples.md §2.1 said a one-tuple could not be written; it could, and one_tuple.cho now pins its refusal |
| #82 | Function values, not yet | The audit’s L1, written before anything forces it. No program here asks: of 594 function bodies, the only pair differing by nothing but its callee is io.write_all against io.error_all, the one sort has one comparator, and none of the 22 enums picks an operation. A whole program can always write a function value as an enum and a match, which is what it would do instead, and the fixtures measure the price: the row becomes the union of the arms’ rows. The decisions: no closures, since a captured capability is authority no parameter names; function values, when an asker arrives, are captureless, non-generic, val, with an exact row in their type. That changes no row rule and brings no row variables, which corrects linearity-and-effects.md §10’s prediction. Callbacks into C need row [] and scalar parameters, which leaves atexit and signal and not pthread_create. Corrected (#127): built. The asker was threads.md’s own spawn, not one of the three §6 named – Type::Fn, Expr::FnValue/Expr::CallIndirect, and codegen on both backends, exactly as designed here, with Rule::NoFunctionValues narrowed to refuse only a generic function’s address rather than every one |
| #83 | Who resolves names | connect.md §1 found that a cancho program only ever connects to an address while a lex-os grant only names hosts, so whoever resolves owns the check. Decided: the builtin, because cancho has to be usable without lex-os: under the perimeter answer a standalone program could use no names and nothing would narrow where it connects. connect takes a name and a port, checks the name against its capability’s bound, then resolves it. Under lex-os the firewall stays as the outer wall, and the perimeter writing its pinned addresses into the guest’s hosts file makes both resolvers agree without DNS egress. The perimeter answer was considered first, and §4.1 says why each of its reasons does not decide it |
| #84 | Parse errors as JSON | check --output json answered invalid JSON for every parse error, 20 of the 214 reject fixtures. The message was the whole rendered refusal, newlines included, and the writer escaped only quotes and backslashes. It also gave those refusals position: null though the span was known. Now the message is the sentence, the position is resolved, and control characters are escaped everywhere JSON is written, authority included. The existing test compared one line of each message, so it could not see any of this; a strict JSON reader now reads every fixture’s answer. Found by replaying the repository’s history through the compiler |
| #85 | Editions, measured | The audit’s L2, designed before Net needs it: adding net to Split would break all 210 files that destructure it. scripts/history.py replays the repository’s history through today’s compiler: 58 of 141 revisions no longer check, 45 of them from the io split. Reading an old io as io_read, io_write recovers none, because rows are exact and the right half depends on each body. A checker-driven migration tool recovers 30, including 15 broken only because Split grew. The rest are a capability parameter and linearity tightenings, which should stay breaks. So an edition holds open only additions, which can simply be absent in older files. A refining change ends support for earlier editions, with cancho migrate as the fix, and a tightening is always a refusal. Edition 1 adds no bytes to identity, and edition 2 will be edition 1 plus Net. Nothing is built yet: the marker lands with Net, after parser.rs (1,998 lines) is split (corrected by #87: the marker shipped ahead of Net, not with it) |
| #86 | Files, and a budget for them | parser.rs (1,998 lines) becomes a table of contents – parse, parse_into, Parser and the token plumbing every child calls – plus items, stmt and expr, with impl Parser<'a> split across them the way FnLowering was. A pure move, verified by token multiset rather than eyeballed: every token of the old file is present in the new ones, and the only tokens added are the pub(crate) 42 methods needed once they cross module boundaries, the three mod declarations, and the impl wrapper each child needs. The same 61 test names survive, moved to parser/tests.rs with #[path]. The largest file is now 531 lines. Prerequisite for Net (editions.md), which adds a field to Split |
| #87 | Editions, measured | The marker itself, ahead of Net after all – editions.md §8 said otherwise and is corrected above. edition N; is a per-file marker, checked once ahead of module/import the same way static is a contextual keyword: it is an ordinary identifier everywhere else. Absent, a file is edition 1 forever, which is why Ast gained an item_editions side table parallel to item_modules rather than a field nothing but this needed to know about. Only edition 1 is known today, so edition 2; and later are refused under a new rule, unknown-edition – there is nothing to name yet, since Net and the per-edition Split are the next slice. Unobservable from any program a file can write until they land |
| #88 | A second outbound program | connect.md §6 and CONTRIBUTING.md rule 3 put the bar for a feature at two askers per half, and outbound had one – so building Net’s outbound half now, as the roadmap otherwise had it queued to do, would have been the discipline it names quietly skipped. examples/report/ is the second: an agent that posts a result, POST <path> with a body, rather than a generic client. It found nothing new about the design questions §1-§4 already settled – but sending a body large enough to matter, which fetch/ never needed to, found two of its own bugs: a region is one 64 KiB arena chunk, and copying a large message into one before sending it could trap; and the header buffer, once that was fixed, was five bytes short of what its own Content-Length digits needed. Both are bounds checks catching a real mistake, not a silent one. Outbound is now 2 and has cleared its bar; inbound is still 1 |
| #89 | A second inbound program | Inbound’s half of the same bar, cleared the same way. examples/collect/ accepts a count of connections in a loop and reads each POST’s body by Content-Length, the two things serve/ never had to do, streamed to standard output rather than materialised for the reason docs/connect.md §8 already paid for. Found nothing new in the program – but found a real deadlock in its own test: collect’s stdout is a pipe smaller than a 100,000-byte body, and reading it only after the HTTP exchange finishes blocks forever the moment the pipe fills, since the exchange cannot finish until the write does. Draining it concurrently, on its own thread, is the fix. Both halves of Net have now cleared the bar. Writing a second corpus file also perturbed the fuzzer’s fixed-seed mutant sequence enough to land on fib(1_000_000), which overflowed the compiler’s own stack: the compile-time evaluator’s recursion-depth guard (fold.rs’s DEPTH, 128) was never measured against a debug build’s 2 MiB test-thread stack, only against fuel. Bisected to 68 safe / 72 not; DEPTH is now 32, with a regression test that reproduces the constrained stack directly rather than waiting on the fuzzer to land there again |
| #90 | Net, slice 1 | Building Net turned out to need more than the marker #87 shipped: nothing in the compiler consumed Ast::edition_of outside the parser’s own tests, so making edition 2 real meant threading it through every place a written name resolves, not only resolve_type_at. Agreed mid-implementation as three slices (connect.md §9): this one, name resolution, then inbound. Built: Net(bound), narrowed like Fs(prefix) (plain prefix, no /-boundary rule); edition 2’s Split, a second declaration of the source name Split rather than a sixth field on the existing one – Split is res by inference, so a field nothing edition-gated could unname would still be consumed by every one of the 210 files that destructure it, exactly the break an addition must not cause; net_out, discharged by an owned World the same unnarrowed way ffi/fs_read/fs_write already are; and connect(net, a, b, c, d, port), which builds the same sixteen-byte sockaddr_in, Linux layout, that examples/fetch/’s address builds by hand, once, in the backend. Three more raw defs.iter().find(name ==) lookups turned up beside the one already fixed – a struct literal, a variant constructor, a destructuring let – each needing the same “latest declaration this file’s edition can see” answer or an edition-2 let Split { .. } = split(world) resolved its pattern against the wrong Split. examples/fetch/ and examples/report/ are not ported: both still need read/write/close on the socket connect opens, which stay extern fn against libc until a later slice, so porting now would widen their report, not narrow it |
| #91 | Net, slice 2 | Slice 2 of 3: connect changes shape from slice 1’s connect(net, a, b, c, d, port) to connect(net, name, port), the shape §4.1 actually decided – octets were only ever a stand-in for “no resolver exists yet”, and getaddrinfo accepts a numeric address exactly as it accepts a symbolic one, so one builtin now covers both. The bound is "host:port"; split once, at compile time, into a host prefix (checked_host, checked_path’s shape minus the /-boundary and .. rules neither Net nor a host name has) and an exact port – a narrower capability names one port, not a prefix of them, so that half is equality and traps before the host is even copied. First build attempt found the bug this design avoids by construction: comparing the dialled host against the whole "host:port" bound trapped on every call, since a 9-byte host is shorter than an 11-byte bound that includes its port. struct addrinfo’s layout (48 bytes, ai_flags/ai_family/ai_socktype/ai_protocol/ai_addrlen/ai_addr/ai_canonname/ai_next) is asserted from POSIX’s <netdb.h> rather than measured the way sockaddr_in’s was, since it was standardised after both supported platforms already existed; getaddrinfo is asked for AF_INET/SOCK_STREAM only, and the backend patches only the resolved sockaddr’s port bytes – the two bytes connect.md §3 already found portable – rather than reconstructing the address itself, since the resolver already wrote the platform’s own correct family bytes. examples/fetch/ and examples/report/ are still not ported, for the same reason #90 gives: neither gap (read/write/close still on Ffi("libc")) closes until a later slice. Fixed on darwin CI, in place: struct addrinfo’s ai_addr/ai_canonname swap byte offsets between glibc and Darwin/BSD’s original RFC 2553 order, caught only by the darwin-aarch64 runner |
| #92 | Net, slice 3 | Slice 3 of 3, the last of the three agreed: bind(net, port), listen(fd, backlog), accept(fd) and the net_in label. bind folds socket, setsockopt(SO_REUSEADDR) and bind into one call, the inbound mirror of connect’s socket+connect, and builds the same struct sockaddr_in examples/serve/ builds by hand – INADDR_ANY’s eight zero bytes where connect’s has four octets, since a listener binds every address the host has. listen and accept take no capability – the fd is what bind already proved the authority for – so both are ordinary fixed signatures, checked the way Builtin::Close is, not the way connect and bind are. A Net narrowed for bind carries just the port as text, net_in’s bound checked by equality the same way connect’s port half already is; only bind performs net_in, listen/accept perform nothing, the same reasoning close’s empty row has. Writing bind’s port check the same shape as connect’s surfaced a bug already latent there: a bound whose port half does not parse as a number was read the same as an absent port, silently permitting any port instead of refusing all of them – both now read a malformed half as -1, a port neither builtin can ever be asked to dial, so a malformed narrowing refuses every call rather than none; this narrows what was promised, never what was reachable. Testing the new builtins against a real accepted connection found a genuine, pre-existing latent bug: every extern fn in this compiler crosses a cancho int as 64 bits regardless of the C function’s own width, while bind’s internal failure-path close (like open_file’s) is declared at libc’s true 32-bit width – one linker symbol, two declared signatures, which Cranelift correctly refuses when a program declares its own extern fn close alongside either. Not fixed here – close is one call a short-lived test process does not need, since the OS reclaims both descriptors on exit – recorded as a known exposure shared with open_read/fs_read/fs_write since before Net existed |
| #93 | fasta and reverse-complement | benchmarks-game.md §2.1’s last deferred row, unblocked once the Game’s own spec turned out to be reachable this session. Both are checked byte for byte against the Game’s own N=1000 reference output, and the measurement corrects a prediction rather than confirming it: bulk-io.md §4.1 guessed fasta would land nearer base64’s 1.59×; it measures 0.54× – faster than C – because its C reference writes with putchar per byte, the ordinary algorithm the Game’s own rules require, while fasta.cho has no primitive cheaper than io.write_all, so the ordinary cancho program is the bulk one. reverse-complement, whose read side is getchar either way there is no bulk read, lands at 1.19×, inside the range the other five programs already described |
| #97 | LLVM backend, slice 1 | The first slice llvm-backend.md §5 (#96) named: cancho-codegen-llvm, textual LLVM IR shelled out to clang -c, behind an additive `–backend cranelift |
| #98 | LLVM backend, slice 2 | Checked arithmetic: every trapping BinOp (Add/Sub/Mul via LLVM’s own with.overflow intrinsics, Div/Rem, Shl/Shr) plus the three bitwise operators, which cannot trap. A real correctness finding: LLVM’s sdiv/srem are undefined, not trapping, on a zero divisor or int::MIN / -1, unlike Cranelift’s, which trap on both – the obvious translation would have been silently wrong on exactly the two inputs this language exists to make defined, fixed with explicit checks ahead of the instruction. Every trap raises the signal #97 already measured, checked by seven tests that read .signal() rather than only status.code() == None – §3.2’s own critique of the existing suite, applied here instead of left as someone else’s gap. A methodology finding while writing those tests: docs/compile-time.md §3 folds a literal-pair operator at compile time into a refusal rather than a runtime trap, so a naive test like 9223372036854775807 + 1 never reaches this backend at all – every trap test instead routes one operand through an impure putchar-calling function the folder cannot evaluate. Comparisons lower too but stay untested end to end: this language has no way to observe a bool outside if/match, named explicitly now as the next gap. tests/accept/llvm_arith.cho exercises all ten non-short-circuit operators once each, the exact printed bytes proving the values rather than only that clang accepted the module |
| #99 | LLVM backend, slice 3 | Control flow: Stmt::If/Stmt::While plus the two short-circuit operators, And/Or – ir.rs’s own comment already said these belonged here rather than in binop’s instruction table. No phi needed, despite §5’s own text once assuming one would be: every local is already an alloca (the first slice’s own design, for clang’s mem2reg), so the block after an if just loads whatever the taken arm last stored, and a while’s back edge is re-entered the same way – fresh loads each time, nothing carried in a register across the edge. &&/` |
| #100 | LLVM backend, slice 4 | Slices and strings: Expr::Bytes/Expr::Len/Expr::Index, plus Builtin::IntOf – necessary plumbing hello.cho needed the same way putchar was necessary plumbing for #97. examples/hello.cho now builds and runs through --backend llvm, byte for byte the same as Cranelift – the loop the first three slices opened (each found hello.cho needed one more thing than assumed) is closed. Two things came out cheaper than the design doc assumed: a string literal needs no instruction, only a declaration, since an LLVM global symbol is already a usable ptr constant wherever Cranelift’s global_value needs one; and bounds checking is exactly one more trap_if, the same shape checked_shift’s range check (#98) already was. One thing did not come free: docs/compile-time.md’s constant folder does not reach Index at all, unlike its own finding about BinOp in #98 – a literal out-of-range index passes cancho check and only traps at run time, so this slice’s bounds-check tests use a literal index directly, with none of #98’s probe-routed workaround. Place::Element (s[i] = e) lowers too, by the same element_address the read side uses; Place::Field/Place::Deref stay refused, waiting on struct/box layout |
| #101 | LLVM backend, slice 5 | Structs and enums: Expr::Struct needed almost nothing new – a struct value is positional already, so its leaves are just every field’s leaves concatenated, the same shape Expr::Tuple would be, and this backend already read a struct this way through Expr::Field (#100). Expr::Enum/Stmt::Match matched cancho-codegen’s own layout exactly: an enum’s leaves are a tag followed by every variant’s payload, not only the constructed one – variant_layout computes one variant’s offset into that whole, shared by enum_lit (writes the tag and the constructed variant, zeroing the rest) and bind_payload (reads the same offsets when a match arm binds them). match_stmt lowers to a chain of icmp eq tag tests. One real subtlety: a match whose every tested arm returns still needs a merge block, unlike if_stmt (#99), which omits one – if/else are syntactically exhaustive, a match is only exhaustive because the checker proved it, so a chain with no wildcard arm still has a fall-through the IR does not know is impossible, and it needs a legal terminator (emit_default_return, factored out of emit’s own fall-through in this slice). Deliberately not in this slice: matching through a reference and Place::Field/Place::Deref, both needing pointer arithmetic into a referent – not needed to make tests/accept/enums.cho build, since its own match binds an owned Point read back through Expr::Field |
| #102 | LLVM backend, -O2 and a first measurement | Asked to benchmark the two backends and found a bug first: run_clang never passed clang an optimisation level, so mem2reg – which the whole memory-backed design leans on – never ran, and the backend was slower than Cranelift on every kernel that builds on both. Fixed (-O2, always). Measured after: on the three benches/ kernels that build on both backends today, LLVM is 8%-54% faster, and on mandelbrot.cho it is indistinguishable from C (0.96x-1.00x) where Cranelift reproduces against-c-and-rust.md’s original 1.6x-1.8x almost exactly – answering that document’s own stated falsifier, corrected in place. Checked, not assumed, and it is not what it looks like: objdump on the -O2 object finds zero SIMD instructions – the gain is register allocation and instruction selection catching this backend up to a competent scalar compiler, not a vectoriser, and overflow-cost.md/check-cost.md’s “a trap is not reassociable” finding holds here too, confirmed on a real backend for the first time. Also inventoried, by trying to build every file under benches/ with --backend llvm: three gaps not previously named on their own – wrapping_add/sub/mul, heap boxing (box/box_slice), arg_count, Type::Float – beyond the already-documented region/alloc_slice |
| #103 | LLVM backend, wrapping_add/sub/mul | #102’s own smallest-first ordering: LLVM’s plain add/sub/mul, no nsw/nuw requested, are already two’s-complement wraparound, so unlike binop’s checked forms this needs no overflow check – three match arms and a four-line helper. Every checked-vs-wrapping pair in benches/ that doesn’t also need region/box_slice now builds on --backend llvm. The measurement looked broken until it was checked: sum_wrapping.cho first reported -99.1% against Cranelift, and objdump explains why rather than excuses it – lexs_run compiles to xor %eax,%eax; ret, because the loop’s body is mathematically a no-op and, unlike the checked twin, nothing in it can trap, so LLVM proves it has no observable effect and deletes two hundred million iterations outright. Cranelift does not. fib_wrapping.cho, which has no such algebraic collapse (objdump: 21 real instructions), is the trustworthy number: -23.3%, in the same range as #102’s other kernels – overflow-cost.md/check-cost.md’s “an observable trap is not reassociable” finding, seen this time from removing the trap rather than adding one |
| #104 | LLVM backend, region/alloc_slice | The bigger of the two gaps #102 named: one malloc in, one free out, a bump pointer in two ptr-typed alloca cells rather than an SSA value, matching cancho-codegen’s own arena exactly (same 64 KiB ARENA_CHUNK). Building sieve/scan against it found two smaller gaps sitting in front, not named until tried: byte_of and Expr::Not. Every program behind only those four gaps now builds on --backend llvm. Measured: sieve/scan are 29%-79% faster than Cranelift – the first memory-bound, bounds-checked kernels this document has compared. Checked with objdump, not assumed: the wrapping halves of both pairs actually vectorise (409 SIMD instructions in scan_wrapping.cho, 22 in sieve_wrapping.cho) where their checked twins – otherwise identical source, diffed to confirm it – compile to zero, confirming check-cost.md’s “an observable trap is not reassociable” on a bounds-checked memory scan, not only arithmetic, on a real backend for the first time |
| #105 | LLVM backend, heap boxing | box_slice/contents/unbox_slice, sharing alloc_slice’s own checked-multiply sizing and reaching for malloc instead of bump. Found a real bug along the way: Box’s own leaf count was never special-cased the way cancho-codegen’s abi::leaves_into already does, so Box[[T]] silently scalarised wrong. Building reduce_checked.cho against this found a second, unrelated gap underneath: a function could not return more than one leaf, closed in general (struct_ty/pack_struct, an insertvalue/extractvalue aggregate, the same shape checked_arith’s own {i64, i1} already is) rather than scoped to two leaves. Every program behind only these gaps now builds on --backend llvm. Measured: reduce_checked.cho – docs/gpu.md’s own kernel – is 61% faster than Cranelift, and objdump confirms the vectorisation split a fourth time (reduce_wrapping.cho: 54 SIMD instructions, reduce_checked.cho: zero) |
| #106 | LLVM backend, getchar/s[a..b] | getchar, the mirror of putchar. Trying it against revcomp.cho found a second gap in front: s[a..b] (Expr::Subslice), the same two bounds checks element_address already makes, over a range. tests/accept/stdin_roundtrip.cho – the repository’s own first //~ STDIN fixture – closes the loop, piped and diffed byte-for-byte against Cranelift. revcomp.cho itself still refuses, and its boundary moved a second time in the same session: past both closed gaps it reaches std.buffer’s Buffer.clear, which needs Place::Field/Place::Deref – named since §5’s fourth slice, materially bigger than either gap closed here, and not closed. docs/llvm-backend.md §7.10 connects it to revcomp.cho explicitly for the first time |
| #108 | LLVM backend, field/deref access | The gap #106 found and did not close: on contact, nine IR nodes rather than two – the named writes (Place::Deref, Place::Field), plus a read-side cluster nothing had built (Expr::Deref, Expr::FieldRef, Expr::FieldAddr, their tuple-shaped counterparts), plus Expr::Tuple/Expr::TupleField, never built either. All nine share two new helpers, field_offset/tuple_field_offset, computing a field’s byte offset once rather than five times over. Building revcomp.cho against this found a third, unplanned gap underneath: write_bytes/write_err (fwrite through stdout/stderr), needed once std.buffer’s own field writes stopped being the reason it refused. revcomp.cho itself now builds, runs, and matches the Benchmarks Game’s own published output on --backend llvm – the boundary that moved twice in #106 is finally past. Measured: 42%-46% faster than Cranelift; objdump finds 23 SIMD instructions, real but modest, attributed to the program’s getchar-based byte-at-a-time read side rather than this slice’s own work. docs/llvm-backend.md §7.12 leaves three gaps: bare alloc, arg_count, Type::Float – plus matching through a reference, named for the first time as its own distinct, still-unbuilt item rather than bundled with the field/deref access this slice closed |
| #109 | LLVM backend, arg_count/arg | argc/argv stashed once into module-local storage by @main, mirroring cancho-codegen’s own emit_c_main; arg’s bounds check the same icmp uge shape every other indexing operation here already uses, then argv[n]’s length computed with libc’s own strlen. Only one of the gap’s two named targets builds past it: fannkuch.cho does, checked against a real argument and not only the no-argument fallback; binarytrees.cho reaches bare Expr::Boxed instead, a different and still-open gap this slice does not touch – the same “a gap can block more than one program and close only some of them” shape #106 found with revcomp.cho. Measured: fannkuch.cho is 22%-28% faster than Cranelift; objdump finds 16 SIMD instructions, a smaller number than scan/sieve’s, consistent with fannkuch’s array rotation not being the loop shape those vectorise on. docs/llvm-backend.md §7.14 leaves two gaps with a benches/ program behind them (bare alloc/Boxed/Unboxed, now including binarytrees.cho; Type::Float) plus matching through a reference, still with none |
| #110 | LLVM backend, single-value alloc/box/unbox | Closes the gap #109 found underneath binarytrees.cho: bare Expr::Alloc/Expr::Boxed/Expr::Unboxed, single-value allocation, arena or heap. Each one a smaller version of a primitive this backend had already built for a slice’s many elements – alloc_slice’s own bump, boxed_slice’s own malloc-and-null-check, both minus their fill loop – sharing a new value_bytes helper, the whole-value counterpart of stride_of’s per-element version. binarytrees.cho builds too now, taking both of #109’s named targets with it rather than only one – checked with no argument and with a real one, matching Cranelift exactly both times. The crate’s and the CLI’s “outside this backend” refusal tests moved from arena_roundtrip.cho (now inside the backend) to floating_point.cho, the next real boundary. docs/llvm-backend.md §7.16 leaves one gap with a benches/ program behind it: Type::Float, now the only gap left blocking more than one (spectral.cho and fasta.cho both) – plus matching through a reference, still with none |
| #111 | LLVM backend, Type::Float | The last gap with a benches/ program behind it. The straightforward reading – add an F64 arm – was wrong: this backend’s LValue carries no type tag the way Cranelift’s intrinsically-typed Value does, so binop/Expr::Neg had no way to tell a float operand from an int one before evaluating it. scalar_kind, a new structural helper, answers that without evaluating anything, walking the same shape the type checker already agreed on. Once it existed: unchecked arithmetic, ordered fcmp comparisons (!= the one unordered exception), float_of/truncate (three checks ahead of fptosi, which is poison rather than trapping on the inputs that matter – no LLVM counterpart to Cranelift’s trapping fcvt_to_sint), bits_of’s NaN canonicalisation, sqrt, and both halves of a previously-unhandled Expr::Neg, found unrelated and closed alongside. spectral.cho and fasta.cho, both named targets, build and match Cranelift exactly – the second checked against the Benchmarks Game’s own published output. Measured: spectral(1500) 45%-56% faster; fasta(1000000) 10%-20% faster; both real vectorisation, float arithmetic never having been checked to begin with. This slice’s own diff pushed emit.rs past CONTRIBUTING.md’s 2,000-line budget – split by concern into body/{mod,arith,control,memory,expr}.rs, the same shape cancho-codegen’s own body/ already is, a pure reorganisation confirmed by rebuilding every fixture and benches/ program before and after. The refusal tests moved a second time in one slice, from floating_point.cho (now inside) to match_a_reference.cho. docs/llvm-backend.md §7.18: one gap left, and it has had no benches/ program behind it since §5 – matching through a reference |
| #112 | LLVM backend, matching through a reference | The last gap this document tracked with any target behind it. Unlike every slice before it, close to what it looked like on the surface – §7.11’s getelementptr-address idiom and the fifth slice’s variant_layout/bind_payload had already built everything it needed. A reference is always one pointer leaf, so the scrutinee evaluates the same way in both modes; only the tag read differs (one more load by reference), and bind_payload gained the address-only mirror of its by-value half – variant_layout’s own offset scaled to bytes via getelementptr, stored as an address rather than a loaded value. Unlike Cranelift’s debug_assert_eq!, this backend returns an Err on the “one leaf per by-reference binding” invariant, matching its own no-panic convention. tests/accept/match_a_reference.cho and examples/tree.cho – the richer target docs/reading-references.md §4 names by name, a three-field variant matched three separate ways – both build and match Cranelift exactly. A &! unique-reference write, with no fixture anywhere in the repo, was checked by hand instead. Every gap this document names a target for is now closed – the refusal fixture moves a fourth time, to tests/accept/bytes_to_c.cho, refusing on a foreign call |
| #113 | LLVM backend, listen/accept | The first slice with no benches/ program or tests/accept/ target behind it – every gap this document tracked closed with #112, so closing Net itself needed scoping directly, smallest sub-piece first, ahead of connect/bind rather than after them. Neither listen nor accept takes a capability – a fd’s authority was already proved once, at bind, a dedicated Expr::Bind IR node rather than a Callee::Builtin call – so both are ordinary fixed-signature libc calls, no different in shape from Sqrt: narrow the int argument(s) to i32, call, sext the result back to i64. connect/bind and Ffi/extern fn are still refused, confirmed empirically – a real edition 2; program’s bind(n, port) names Bind specifically on --backend llvm while running to completion on --backend cranelift – so there is no way to get a real bound fd out of a --backend llvm program yet, and tests/accept/listen_accept_bad_fd.cho checks both builtins against a deliberately invalid one instead: EBADF, matching on both backends, with no real socket or live connection needed |
| #114 | LLVM backend, bind | The second of Net’s four builtins, after #113’s listen/accept, and the first that reaches a real fd rather than a deliberately invalid one. Folds socket, setsockopt(SO_REUSEADDR) and bind into one call, the same struct sockaddr_in cancho-codegen’s own bind builds by hand – family bytes 2, 0 needing no platform branch, the same fact #77/connect.md §3 already measured. The one genuinely new shape: socket/bind can each fail, returning -1 rather than trapping (only a bound mismatch traps, checked first) – the first --backend llvm expression needing a value conditional on which of three runtime paths ran, where every earlier slice either trapped (nothing to merge) or was a Stmt-level if writing into an already-alloca’d var. Solved with a plain alloca i64 result cell, written in each branch and loaded once at the merge label, the same “no phi” rule if_stmt/while_stmt already follow rather than a new one. Checked against a real accepted connection: a new differential test builds a listener on each backend, connects a real TcpStream from the test process, and checks both exit 0 – the first Net-capable program --backend llvm has ever actually run. connect (getaddrinfo-based host resolution, genuinely larger) and Ffi/extern fn are still refused, so nothing built yet can read or write what it accepts |
| #115 | examples/seek, an agent-shaped tool | Not a GNU port like base64/cut/sort – no upstream this is checked against, because the question is not compatibility. docs/agent-tools.md asks whether the properties this repo already built for other reasons – a legible authority report, no silent truncation (line-reading.md’s own finding about cut), no UB on adversarial input – add up to a tool genuinely better shaped for an agent’s own tool-use loop than a straight port would be. A literal-text search over named files, built on std.bytes.find and the same heap-growing buffer/open_read/file_read machinery sort.cho/cut.cho already use. cancho authority on it names exactly seven labels and bounded: true, pinned by agent_tools.rs. Two honest limits found building it: M2’s narrow takes a literal, so no flag can narrow this tool’s Fs to a directory at invocation time – a supervisor gets a scoped copy by substituting the literal at build time, not a --root flag, the same shape #114’s own bind test already uses to substitute a port; and -m caps matches across every file rather than per file the way GNU’s own does, because an agent bounding output is bounding its own context, not any one file’s share of it. Checked on a file containing a NUL byte and a lone 0xFF, found and printed byte for byte with no crash and nothing checked for UTF-8 |
| #116 | LLVM backend, connect | The last of Net’s four builtins, after #113’s listen/accept and #114’s bind – genuinely the larger remaining piece, as #114 predicted. checked_host (mirroring cancho-codegen’s own function) is this backend’s first loop built for Net: a dialled name copied into a 256-byte stack buffer while its prefix is checked byte by byte against the capability’s bound, in the same “cursor in an alloca i64 cell, no phi” shape the arena-fill loop already established – not a new mechanism. connect then builds a struct addrinfo hints, resolves with getaddrinfo, patches the port the same big-endian way bind already does, and calls socket/connect – three failure points funnelled into bind’s own one-result-cell shape. Checked against a real accepted connection, both directions: a differential test connects each backend’s client to a plain std::net::TcpListener, and this slice’s own session built an all-LLVM listener-and-client pair talking over real loopback – the first time two programs this backend built have ever talked to each other. The two bound-mismatch traps (host outside the prefix, the exact-port check) are checked by signal, matching Cranelift. Net is now fully built on --backend llvm. Ffi/extern fn is the only gap left – what a program needs to read or write what it accepted or connected to, not merely open the socket |
| #117 | LLVM backend, Ffi/extern fn, and a correction | Callee::Extern mirrors cancho-codegen’s own arm – a capability parameter never crosses to C, everything else crosses at cancho’s own widths (an int is i64 regardless of the C function’s own parameter width) – reusing Callee::Fn’s own call-and-unpack shape, factored out as emit_call once both needed it; scalar_kind gained the Callee::Extern arm it was missing. Checked against tests/accept/bytes_to_c.cho unmodified (a &r [byte]-crossing write) and a fresh labs(-5) == 5 on both backends. Checking real programs against this slice found #116’s own “Ffi/extern fn is the only gap left” claim false – Fs (fs_read/fs_write/open_read/file_read) had been unbuilt the whole time, unnamed because no slice had tried a real Fs-using program against this backend until examples/seek/ did here. Corrected in place across lib.rs, llvm-backend.md, docs/README.md and README.md rather than left standing. The boundary fixture moves a sixth time, to tests/accept/file_handle.cho. A second finding: a user-declared extern fn naming a symbol this backend already declares for Net (socket et al.) at a different width makes clang correctly refuse to link – not new, the identical exposure #92’s own entry above already recorded on Cranelift for close, left unfixed here for the same reason: none of the existing network examples needs a different socket call than this backend already declares for Net itself |
| #118 | LLVM backend, Fs, and a byte-comparison bug it found | checked_path is checked_host’s own loop (#116) plus a ..-traversal refusal and a /-boundary check, reused by fs_read/fs_write/open_read; file_read and a per-thread errno accessor round it out. tests/accept/file_handle.cho, unmodified, builds and matches its own directives exactly on --backend llvm. Checking real programs found two things, and both are fixed rather than documented away: read/write’s own unconditional declares broke bytes_to_c.cho – a program that had already been working (#117), unlike socket/bind’s already-accepted exposure – fixed by declaring them only when a program’s own extern fn doesn’t already claim the symbol. And compare had hardcoded icmp {cc} i64 regardless of operand type since this backend’s first slice – silently correct for int, silently ill-typed for byte/bool – caught only once examples/cut//examples/seek/ exercised std.bytes.find’s raw byte comparison, which nothing in eighteen prior slices had. Fixed by threading binop’s own lhs_kind through to compare, checked directly (byte_comparison_uses_the_right_width). The boundary fixture moves a seventh time, from bytes_to_c.cho (inside since #117) to tests/accept/static_data.cho, refusing on Expr::Static – docs/compile-time-data.md’s whole feature, found unbuilt the same way Fs was, by checking rather than assuming this backend’s Expr match was exhaustive |
| #119 | LLVM backend, Expr::Static, Expr::BitNot, and the last gap | Expr::Static lays each static’s already-evaluated values out as one read-only global, packed at the same byte-or-eight-byte stride every other slice in this backend uses; a reference to it costs only the symbol name, declared once for the whole program rather than once per reader the way a string literal is. tests/accept/static_data.cho, unmodified, matches Cranelift byte for byte. Checking whether that was really the last gap – by walking every Expr variant against this backend’s own match, rather than trusting the running “named gaps” list a fourth time – found Expr::BitNot unbuilt too, invisible until now because tests/accept/bitwise.cho’s only use, ~0, is a literal the checker folds away before codegen ever runs. Fixed as Not’s own xor, at i64/-1 in place of i8/1; checked against a value putchar’s own echo keeps unfoldable. With both closed, expr’s match has no unhandled Expr variant, so its wildcard “not built yet” arm came out entirely – rustc itself now refuses to build this crate if a future Expr variant goes unmatched, a stronger guarantee than any test here gave before. The same enumeration over Callee::Builtin found nothing missing either. The boundary fixture does not move an eighth time: every file in tests/accept/ and every program in examples/ was built against --backend llvm directly in this slice’s own session, and all of them succeed. The one program that still refuses, examples/collect/collect.cho (and fetch//report//serve/ alongside it), does so for #92’s own already-accepted extern fn socket collision – not a missing node |
| #121 | std.crypto, SHA-256, and a static never gated by reachability | The first slice of moving lex-os off Rust: crates/lex-os-audit’s hash-chained log calls sha2::Sha256 on every entry, so a port needs a hash function to exist first. FIPS 180-4, in masked 64-bit int arithmetic – mask32(x) = x & 0xffffffff at the points the spec says to reduce mod 2^32, so the checked + underneath never traps and no wrapping_add is needed. The round constants are two static items – the first real consumer of Expr::Static outside its own design doc and tests/accept/static_data.cho. Checked against four vectors computed with the system sha256sum rather than from memory, including the 56-byte input that forces the multi-block padding path this backend’s earlier fixtures never reached; tests/accept/sha256.cho matches on both backends. Building it against AGENTS.md §7’s “an unused declaration costs nothing” test found a real gap static_data.cho could never have exposed on its own: unlike a function, a static was never gated by mono’s reachability worklist – every static a program declared was evaluated and pushed into Program::statics whether or not anything read it, so std.crypto’s tables reached the object file of any program that merely imported the module. Fixed in cancho-ir: an exhaustive, no-wildcard-arm walk finds which static indices a reachable function actually names, closes over statics reading earlier statics (safe, since a static may only read one declared before it), and renumbers what survives before evaluation runs – checked against static_data.cho’s own cross-static reference and a fresh case where the middle of three statics is dropped and the third has to renumber past it, on both backends |
| #122 | c_int, fixing a real bug in extern fn’s foreign return | Scoping examples/vsock/ – connecting over AF_VSOCK, the channel lex-os-guest uses to reach its host supervisor – found a real, previously-undiscovered bug in both backends, not specific to vsock: a foreign call declared -> int read the C function’s return as a full 64-bit register. Correct when the real C function genuinely returns 64 bits (labs returns long, why its own test never caught this), but most libc calls (close, connect, socket, bind, listen, accept) return a 32-bit C int, and a failing call can leave the upper 32 bits of the return register zero rather than sign-extended – close(999) read back as 4294967295, not -1, confirmed byte-for-byte against equivalent C. No universal fix exists: extern fn’s syntax cannot distinguish a 32-bit int from a genuinely 64-bit long/ssize_t, both spelled -> int today, so assuming either width for every declaration would fix one case and silently break the other. c_int names the real width explicitly, in return position only – the call crosses at 32 bits and sign-extends, and the value is exactly int everywhere else in the program. Every existing socket declaration across examples/serve/, fetch/, report/ and collect/ is migrated; read/write stay plain int (ssize_t, genuinely 64-bit). Their own < 0 checks had this exact bug the whole time, untested only because the shipped suite exercises success paths, never a real bind/accept failure. examples/vsock/ is fixed and unblocked by the same change; docs/net.md §5’s outbound count moves to 3 |
| #123 | std.crypto’s second slice, SHA-512, and the logical shift it needed | Ed25519 (lex-os-capsule’s signing need) hashes with SHA-512 (RFC 8032), not SHA-256 – crypto.md §7 named the dependency and deferred it. compress512 is compress() again at double the width and eighty rounds; wrapping_add does directly what mask32(checked +) had to simulate for SHA-256’s masked word. A full-width word surfaced two gaps SHA-256’s narrower one hid. Every rotate SHA-512 needs is logical, and this language’s >> is arithmetic by design (bitwise.md §2) – built the logical shift out of the arithmetic one by masking off exactly the bits sign-extension might have gotten wrong, and low_mask64(63) needed its own literal since (1 << 63) - 1 traps. And a 64-bit literal with the top bit set does not parse at all: every such constant in sha512_h0/sha512_k is spelled (hi << 32) | lo instead, each half reconstructed and checked against the original value before being written down – a mechanical transform, not a per-constant judgement call. H0 and the round constants are independently derived (integer square/cube roots) and checked against Python’s hashlib.sha512 and the system sha512sum on four vectors; both backends agree, and std_declarations_cost_nothing_unless_called needed no change since SHA-256’s reachability fix already generalized. §5 names what Ed25519 still needs beyond this – field arithmetic std.bignum cannot yet do, curve point operations, a constant-time posture – and deliberately starts none of it |
| #123 | Content-addressed VCS: what cancho would need | Shipped in the same PR, and unrelated to it: a design document, not a std.crypto change. hash-stability.md’s plateau measurement left one open question – whether 27 commits touching zero builtin/effect/prelude files is enough – and this document answers it yes, conditional on tagging every op’s edition from day one (editions.md’s own mechanism, applied before there is a second edition to need it rather than after). Read file by file rather than re-estimated: lex-vcs’s ~81% (operation.rs, attestation.rs, signing.rs, merge.rs, merge_session.rs, issue.rs, predicate.rs, op_log.rs, history_index.rs) is String/BTreeSet<String>-keyed and stated as decoupled in its own doc comments; compute_diff.rs+diff_to_ops.rs+body_merge.rs are exactly the ~1,500 CExpr-walking lines that need a native rewrite (449+617+411=1477, to the line). The apply→gate pipeline needs no change at all, because gate.rs already defers every type judgement to the caller’s checker – which turns out to already answer this repository’s own linearity worry about auto-merge (#55): a structurally-clean merge that double-consumes a linear value is exactly what the gate already exists to catch, so the open risk is a higher rate of merge-then-gate-rejects than lex-lang sees, not a soundness gap, and it is named as worth measuring rather than assumed. Recommends a native cancho-vcs crate – the same “shares the idea and no code” relationship lex-os/cancho already committed to, checked against zero shared lines in this repository’s own history – over a Cargo.toml dependency on lex-lang |
| #124 | cancho-vcs’s foundation | vcs.md §8’s own next slice, built: crates/cancho-vcs, a native crate depending on nothing from lex-lang. OperationKind is scoped to what cancho can actually populate – AddFunction/RemoveFunction/ModifyBody, no budget_cost field at all, because budget.md already settled cancho has nothing for it to hold, so it is not declared rather than declared and permanently None. Operation carries Ast::edition_of’s own u32 from the day the crate is born, per §6. One thing changed on contact with the code: OpId is BLAKE3, not lex-vcs’s SHA-256 – canonical-ast.md already picked BLAKE3 for cancho-id’s own hashes for reasons that apply equally to an operation, so matching lex-vcs’s algorithm would have cost a second hash dependency for no reason but appearance. vcs.md §5 corrected in place. The golden suite is built from real cancho-id output, not invented strings: an AddFunction‘s sig_id/stage_id checked directly against cancho ids’ own CLI output on the same source, and two ModifyBodys over sources differing by one operator confirm the signature hash holds while the body hash does not. The gate, attestation, signing, merge and issue tracking vcs.md §3 said port largely unmodified are still unbuilt; §8 (updated) names the order – gate next, since it is now checkable against real code |
| #125 | cancho-vcs’s gate, op log and attestation | gate.rs type-checks a candidate program with no code changed at the lex-vcs boundary – vcs.md §3’s own claim, checkable now rather than only argued: check_candidate parses and lowers assembled (name, source) pairs and reports each refusal’s rule tag, deliberately skipping the CLI’s main-shape rows since an operation can be about a library declaration with no entry point. op_log.rs is loose-file JSON, one file per OpId, atomic via same-directory rename, idempotent on a repeat write – and get recomputes each record’s OpId from its own payload and refuses a disagreement, the one thing put itself cannot produce but a hand-edited or bit-rotted file could. attestation.rs reimplements lex-os-audit’s own generic Chain<E> as an idea, no shared code – BLAKE3 rather than lex-os-audit’s SHA-256, for the same reason OpId already picked it. No seals yet at this point (signing needed Ed25519, which did not exist), so honestly the weakest of lex-os-audit’s own three documented layers: catches an edited payload or a reordered entry, not a truncated tail or a holder who recomputes – checked directly, not just asserted, including the truncation case verifying when it should |
| #125 | std.ed25519, and Ed25519 seals for the attestation log | Two consumers of the same primitive, shipped in the same PR, each closed with the tool that actually fits it. std.ed25519 answers sha512.md §5’s three named gaps – field arithmetic, curve point operations, a constant-time posture, the last one answered no rather than deferred again. Every “big number” is a bit-serial [byte]-slice toolkit (compare, add, subtract, shift-by-one, schoolbook multiply), not a radix-limbed one: sha512.md’s own carry-chain bug was one operation’s worth of risk, and this is that risk hundreds of operations over, so speed is traded for a toolkit simple enough that hand-computable cases caught its one real bug – bn_modpow/sign/verify reuse one scratch buffer across many calls, and the reduction step assumed its output arrived zeroed instead of zeroing it itself, found by 3^1 mod 7 returning 1. p/L/d/√-1/the base point are each derived and checked (on-curve, correct order), not transcribed. Checked against four fresh openssl-generated keypairs, in both directions: key derivation and signing match openssl byte for byte on all four, and verify accepts a real openssl signature over a 512-byte message this module never produced. This module is explicitly not what cancho-vcs uses for its own attestation log – that gains sealed_with/is_sealing/verify_seals via ed25519-dalek instead, the same crate lex-os-audit already uses correctly and safely, with the seal kept outside compute_hash’s own input so turning sealing on partway through a log cannot retroactively change anything already written |
| #126 | cancho docsync: generated facts instead of hand-edited ones | Ported lex-lang’s lex doc-sync mechanism – same docsync.toml shape, same <!-- docsync:begin/end ID --> markers, same --check contract, sharing the idea and no code (vcs.md §5’s own precedent). The concrete problem: net.md’s own outbound/inbound tally had been hand-edited twice in this repository’s history because nothing failed when it went stale. cancho repo-stats now computes it fresh – mirroring tests/conformance/net.rs’s own scan exactly, so the two can never silently disagree – plus crate/example/doc counts and total Rust LOC, spliced into one new block in README.md. docsync --check is the gate’s fourth step (CLAUDE.md) and a new CI job. Removed (#136): the mechanism never gained a second target, and the one it had — the “Repo, by the numbers” line in README.md — was asked to go. A generic mechanism with exactly one consumer and no path to a second is not generic, so the whole thing (docsync.rs, repo_stats.rs, docsync.toml, the CI step, the gate’s fourth clause) went with it rather than being left running with nothing to check |
| #127 | --backend llvm becomes the default | Every gap llvm-backend.md ever named a target for had closed; this is the PR that acts on it, closing the 1.17×–2.58× gap to C for every build rather than only opt-in ones. Flipping the default is what turned two long-accepted, theoretical exposures into live regressions the same day – which is exactly what a default change should do to anything nobody had asked to pay for yet, so both were fixed rather than reverted around. docs/ROADMAP.md #92’s own “already-accepted” socket collision: every socket-declaring example in the corpus (serve/, fetch/, report/, collect/, vsock/, agent_guest/, agent_supervisor/) refused to build on --backend llvm the moment it became the default, because this backend declared socket/bind/listen/accept/connect/getaddrinfo/freeaddrinfo/close/creat/open unconditionally rather than only when a program’s own extern fn did not already claim the symbol – the guard read/write already had, extended to every other libc name this backend declares. Two backend bugs neither llvm-backend.md nor Cranelift parity testing had ever found, both invisible until something not opt-in ran them for the first time: Expr::Unboxed had no scalar_kind arm at all (docs/tuples.md’s identity fixture, crashing rather than refusing – fixed the same way Deref/Contents already were, leaves_of on the unboxed type); and int::MIN % -1 trapped by signal instead of answering 0, because checked_div applied the same is_min && is_neg1 check to Rem that correctly guards Div, when a remainder can never overflow – fixed with a real branch around srem (a select was tried first and rejected: both its arms evaluate, so srem would still see the undefined pair with its answer merely discarded). Two tests that pinned the socket collision as permanent (a_program_outside_this_backend_is_refused_not_panicked, a_program_outside_this_backend_is_refused_through_the_cli) now prove the fix instead; two tests asserting Cranelift-specific object-file byte-identity (a_tuple_emits_the_same_object_as_the_struct_it_replaces, std_declarations_cost_nothing_unless_called) are pinned to --backend cranelift explicitly rather than silently retargeting whichever backend is the default this month, and the second attempt at that pin found --backend llvm’s own object output is not reproducible across processes at all – run_clang’s temp-file name embeds std::process::id(), and that name leaks into the object. Same PR, second slice: c_ptr, edition 3. reach.md §3.1’s “every opaque handle is out” is corrected in place – Type::CPtr is a real, distinct type an extern fn can return or take, checked for equality and nullness only and never collapsing to int the way c_int does, which is what keeps arithmetic, dereference and coercion ordinary type mismatches rather than silently meaningless. null_ptr() is the one non-foreign producer, needed because OpenSSL’s own NULL-on-failure convention (the motivating case, grounded against the real /usr/include/openssl/ssl.h and nm -D libssl.so.3) has no other way to build the comparison value the no-coercion rule forbids constructing from an int. tests/accept/opaque_pointer.cho round-trips a real handle both directions through libc’s own fdopen/fclose on both backends (Cranelift: pointer-width int; LLVM: its own distinct ptr kind, LKind::Ptr, whose zero() was already "null" for exactly this). The real OpenSSL example was not built: every example this project has ever linked needs only libc, which the compiler always links, and nothing in cancho build can yet ask for -lssl -lcrypto – a real, separate “link an external library” feature this document tracks rather than assumes. The fixture that was built found a bug with nothing to do with c_ptr: the first attempt declared fopen’s own two string parameters directly and segfaulted on both backends, because a &r [byte] parameter already crosses as a pointer and a separate length (strings.md §6), and that phantom length register silently misaligns any parameter declared after it – safe only when a byte-slice parameter is the last one, which is why write/read’s own declarations (and every real OpenSSL signature opaque-pointers.md §1 grounds) already put it there. Rewritten around fdopen(fd: int, mode: &m [byte]) instead of chasing that pre-existing gap. Same PR, third slice: threads.md, a design doc, not yet built. function-values.md §5 and reach.md §3.3 both named the wall and stopped; this finishes the sentence. pthread_create’s one void *arg is reach.md §3.1’s un-crossable pointer restated, and c_ptr cannot smuggle a capability through it either – it is opaque by design, the same “smuggling does not help” argument reach.md §3.1.1 already made about malloc. Proposes spawn/join as compiler primitives instead, the same kind fork/box/split already are, never touching the C ABI: body is a captureless function value (function-values.md §4.2, its own first real asker), payload is moved into it, join is mandatory (res Thread[R]). The soundness argument – a shared or unique reference may cross provided the thread is joined before the region it belongs to closes – is linearity-and-effects.md §5 rule 4’s existing escape check (Type::mentions) asked about one more kind of value, not a new rule; Rc[h] T must be excluded, since its non-atomic count (§9) would race across a real thread boundary. Written up and reviewed before anything is built, the same gate c_ptr went through, materially larger given it needs function values first. Same PR, fourth slice: function values, built. threads.md’s own prerequisite, taken further than the design doc alone: Type::Fn(Vec<Type>, Vec<FnLabel>, Box<Type>) in cancho-types (a new FnLabel mirroring cancho-ir’s Label rather than depending on it, since the dependency runs the other way), fn(A, B) -> [row] R parsed as a type, and two new IR nodes – Expr::FnValue(FuncId) (the target’s address, region parameters freshly instantiated exactly like a call’s) and Expr::CallIndirect (checked against the value’s own type: arity, argument types, and the row unioned into the caller’s exactly as a named call’s is). Both backends: Cranelift takes the address with func_addr and calls through call_indirect against a Signature built from the value’s type; LLVM reads a global symbol directly as a ptr – no instruction needed – and spells the callee’s signature explicitly at the call, since an opaque pointer carries none of its own. Rule::NoFunctionValues narrows to refuse only a generic function (no address until its type arguments are known) or anything that is not a named top-level function; a plain int local stays refused under not-a-function, unchanged. Verified past the type checker: tests/accept/function_value.cho calls a region-polymorphic, effectful function through a value taken twice (proving val copy semantics, not a move) on both backends, real output checked. Rule::NoFunctionValues’s own golden fixture (tests/reject/function_as_value.cho) pinned a captureless function’s refusal as permanent – rewritten to a generic function, the case that is genuinely still refused, the same “obsolete pin” pattern --backend llvm’s own socket-collision tests hit above. spawn/join themselves are not built; threads.md still needs its own review first |
| #128 | spawn/join, the single-leaf slice | threads.md §2 built, deliberately narrower than its own illustrative signature: this compiler has no way to synthesise a trampoline function for a multi-field payload, so spawn’s payload and body’s return type are each restricted to one pointer-width leaf (int, bool, c_ptr, a captureless function value, or a single non-slice reference), checked by a new Rule::ThreadPayloadType. That restriction is what makes the whole slice possible with no new compiler machinery: body’s own compiled entry point – the same symbol an ordinary call already targets, reachable through the function-value work #127 just built – becomes pthread_create’s start routine directly, since every leaf this slice allows already lowers to one pointer-width value. res Thread[T, R], two type parameters rather than the one sketched in the design doc: T, the payload’s own type, rides along purely so its region is tracked, which makes §3’s entire soundness argument – a handle cannot escape past the borrow block that gave its payload a reference – fall out of Type::Named’s already-generic mentions/regions_into walk into its own type arguments, with zero new checker code; join is Thread’s one consumer, mirroring Box/unbox. Join needed its own IR node, Expr::Joined, because its return type varies per call site and the backend needs to know at codegen time how many leaves to read back – discovered before any backend code was written, not after. Both backends declare pthread_create/pthread_join on demand (Cranelift’s existing libc_fn helper, already used by malloc/free; LLVM’s own declare_libc_unless_own) rather than through new module-level plumbing that was tried first and reverted once libc_fn turned out to already do the job. Two real ABI bugs, found by running compiled programs, not by review: LLVM’s pthread_create call used a bare integer literal where its ptr-typed parameter required null or an inttoptr conversion – invalid textual IR, caught by clang refusing to assemble it; Cranelift’s Callee::Builtin(Spawn) arm indexed args[0]/args[1] positionally without accounting for a () payload contributing zero leaves (shifting body’s address into the payload slot) or for a byte/bool payload’s narrower Cranelift type needing uextend to the signature’s pointer-width parameter – both fixed, and both would have shipped silently wrong without tests/accept/spawn_join.cho actually running on both backends. Verified past the type checker, threads.md §5 step 2 in full: tests/accept/spawn_thread_ids.cho checks real, distinct pthread_self() values from main and two workers (not simulated), doubling as the “shared reference crosses into more than one spawn, still readable by the spawning side after every join” case from §3 – one borrowed Ffi("libc") capability is payload for two threads and read again afterward; tests/accept/spawn_parallel_sleep.cho proves genuine OS-level parallelism, not a disguised sequential call, by timing four real 200ms usleeps joined together and asserting comfortably under twice one sleep’s wall-clock time on both backends (spawn_and_join_run_concurrently_not_sequentially). tests/reject/spawn_handle_escapes_borrow.cho checks §3’s soundness claim directly – a Thread[&r int, int] returned out of the borrow r block that opened r, unjoined, is refused by the pre-existing reference-escapes-region rule, no new rule needed, exactly as predicted. tests/reject/spawn_payload_type_not_supported.cho checks this slice’s own wall, a struct payload refused before codegen. Left for later, honestly: an owned (not borrowed) capability payload and the Rc[h] T exclusion threads.md §5 named – the latter is moot today since Rc is not itself an implemented type in this compiler, so nothing of that shape can be written to exclude |
| #129 | spawn/join: an owned capability payload | threads.md §5 step 3, built – and its own original text turned out to be wrong. It assumed an owned File/Io is a multi-field struct under the hood needing the same compiler-synthesised trampoline the general struct-payload case does; checked against abi::leaves_into on both backends before writing any code, File is exactly one leaf (the fd, its own dedicated ABI arm) and every zero-field capability – Io, Ffi, Fs, Args, Heap, all declared with no fields at all in defs.rs’s prelude_types – is exactly zero, the identical shapes #128’s own codegen already handles for int and (). So the fix was a wider crosses_to_a_thread allowlist in the checker, not new machinery: zero lines changed in either backend. A real, pre-existing gap surfaced writing the first fixture, unrelated to spawn: a function taking Io by value (not &!i Io) had never been written anywhere in this repository’s corpus, and the first attempt declared [io_write] on it and was refused with “declares io_write but never performs it” – not a bug, but defs.rs’s own discharged_by mechanism (owning Io outright discharges io_write/io_read/err_write, the same reason main itself is allowed to declare [] while performing effects through the World it holds) applied one level deeper than anything had exercised it before. tests/accept/spawn_owned_io.cho moves an owned Io into a thread that writes to the real console; tests/accept/spawn_owned_file.cho opens a file in main, moves the resulting File into a thread that reads and closes it there – real I/O performed by a second OS thread holding the only reference to the capability that authorises it, checked on both backends (the_two_backends_agree_on_spawn_owned_io/_file). tests/reject/spawn_owned_capability_reused.cho closes §5 step 4’s other named companion: main moves io into spawn and then calls release(io) again, refused by the pre-existing linear-use-after-move rule – confirming §3’s “the existing rule, not a new one” claim by running it rather than only asserting it, same as use-after-move for any other res value. threads.md’s status header and §5 corrected in place, including the wrong original claim about step 3’s own difficulty. Left open: a capability shaped like a genuine multi-field struct (none exist among today’s capabilities, so this step’s trampoline-free answer is untested past File/Io’s own accidentally flat ABI shape) and the Rc[h] T exclusion, still moot until Rc exists |
| #131 | threads.md §5 steps 3 and 5, measured and decided not yet | The two items #128/#129 left open – a struct-shaped owned capability payload and the Rc[h] T exclusion – both trace to the same missing piece, a compiler-synthesised trampoline function (Rc doesn’t exist as a type either way, so its exclusion needs one before it needs anything else). Rather than build that trampoline speculatively, AGENTS.md §7’s own rule was applied and actually checked rather than assumed: a repo-wide search across all four reachable repositories – cancho, lex-os, lex-lang, lex-gpu – for any program wanting a multi-field spawn payload today. None. lex-os-guest’s own reasoning loop, the concrete production target threads.md §2 itself names, is single-threaded by design with no comment or TODO anywhere wanting concurrency; its only std::thread::spawn calls are host-side pipe-drain threads, unrelated to cancho’s primitive. lex-lang’s own conc.spawn_thread (PR #660 there, an independent design for the identical problem in a different language) answers “how does a background task get its state” with a captured, zero-argument closure rather than an explicit payload – not proof, but a real signal that this problem shape tends not to want a fat payload even where a language is free to offer one. Decided not yet, the same verdict self-hosting.md reached the same way: nothing found is a hard blocker, nothing asks for it today, revisit when an asker exists rather than on a schedule. threads.md’s status header, §4’s Rc bullet, and §5 corrected in place – including a stale line in the status header itself, still claiming step 4 (the unique-reference-across-join companion) as a future slice a full PR after #129 had already built it |
| #130 | examples/results_stub/: the first lex-os component ported to cancho | The cancho epic issue’s own long-unchecked sequencing box, closed: not an analogue or a simulation like vsock//agent_guest//agent_supervisor/, but a real port of lex-os/crates/results-stub – the single allowed-egress target the lex-os demo’s manifest narrows to (lex-os issue #10) – checked line by line against the Rust original rather than reimagined from its description. Same discipline serve//collect/ already established: hand-rolled HTTP/1.1 over raw libc sockets through Ffi("libc"), no Net capability. Two honest narrowings, both because of what this language’s foreign-call boundary can and cannot cross (docs/reach.md §3), not oversight: no peer address (getpeername’s real signature wants a socklen_t * the kernel both reads and writes – a pointer to a plain scalar, neither the &[byte] “pointer and length” shape nor the “value across, nothing back” shape any foreign call here can express; accept’s own two NULLs in serve.cho already made the identical choice for the identical reason), and a request body past its 200-byte preview buffer is drained from the socket but not kept past that, since an unbounded body has nowhere to live in a 64 KiB arena (examples/cut/’s own line-length cap, same reasoning). A real behavioural gap found by running it, not by reading the Rust source: Rust’s println! is internally line-buffered regardless of whether stdout is a terminal, so the real results-stub’s log is visible per request; this language’s write_bytes is fwrite, fully buffered like any C program’s once redirected to a file, so the first working build’s log only appeared in bursts. Fixed with one extern fn fflush call, NULL flushing every open stream, the same “pass 0 for a NULL pointer” idiom accept’s own unused out-params already established. crates/cancho/tests/conformance/backends.rs’s the_two_backends_answer_the_results_stub_port builds it on both backends, sends a real HTTP request over loopback, and checks the response and the request-log line both – not simulated. docs/net.md §5 recounts inbound to 4 (net.rs’s own counting test, which found this file the moment it existed, exactly as it is built to) |
| #137 | Linking beyond libc | opaque-pointers.md built c_ptr so a handle like SSL_CTX * could cross, but its own §5 could not compile the TLS example it was designed for: cancho build only ever links libc. -l/-L on build/run, passed to cc unexamined between the object and -o – measured against this project’s own ld that the order matters, not assumed. examples/tls_client/ is the proof: a real handshake and encrypted round trip against openssl s_server -rev, on both backends. Building it found two things unrelated to linking: c_ptr cannot name an ordinary function’s own parameter or return type, only an extern fn’s (opaque-pointers.md §4, easy to miss), so the handshake/write/read became one function instead of three; and the program’s own extern fn connect collided with edition 2’s connect builtin, fixed by giving the raw socket its own file on edition 1 while the rest stays on edition 3 – docs/editions.md §7’s per-file marker doing exactly the job it was designed for. net.md/net.rs recount outbound to 5. Not CI-gated: this project’s own darwin-aarch64 runner cannot be assumed to have OpenSSL in cc’s default search path, so the transcript is hand-verified in foreign-linking.md §4 instead, the same fallback opaque-pointers.md §5 pre-approved, for a different reason than the one it named. reach.md’s TLS row moves from Partial to Yes |
| #138 | A package system | Design only, answering modules.md §7 and standard-library.md §2.1’s deferred question now that the two things it waited on exist: cancho-vcs and editions. The load-bearing finding: lex-lang’s own working package manager, lex pkg, is built on crates/lex-store’s Store – a content-addressed repository keyed by declaration hash, resolved by pinning a (store, head) – which is cancho-vcs’s own architecture, not a coincidence. Proposed: a package is a cancho-vcs store at a pinned head, resolved the way lex-store/src/deps.rs already resolves one, re-checked under the consumer’s own compiler rather than trusted from a lock file – hash-stability.md’s 71% is why a pin cannot assert it still compiles. lex-os-capsule’s “the consumer’s grant, not the publisher’s declaration, is the ceiling” becomes a build-time check via authority --output json; lex-os-authority’s derive/diff/narrow is reused whole for “did this bump widen what I’m exposed to.” modules.md §2’s “a call encodes the callee’s hash, not its spelling” extends into the dependency graph for free: a name is chosen once and resolved by hash forever after, which is the direct answer to typosquatting. Stated as a design commitment: no code ever runs to resolve a dependency, the same reason this project has no macros. Not scoped to build yet – §6 names the smallest next design doc |
| #139 | vcs resolve | package-system.md §6’s own named next slice, built: a real second store, never trusted from its own lock. Found a prerequisite the design doc had not named – neither OpLog nor Manifest store a declaration’s actual source, only its hash, so there was nothing to re-typecheck. Blobs (crates/cancho-vcs/src/blobs.rs) is the missing piece, BLAKE3-hashed the same way Operation::op_id already is. cancho vcs resolve <store-dir> groups a store’s pins by source file, re-parses and re-typechecks each one under today’s compiler, then re-derives every declaration’s identity and diffs it against what the manifest pinned – refusing, every mismatch at once, if a pin no longer matches its own source, the source no longer type-checks, or a blob is missing. Verified by hand against all four cases before writing them as tests. Not built: resolve checks one store against itself, not a consumer’s dependency graph – no lock file, no recursion across a closure of stores, no diamond-conflict detection. docs/package-system.md’s own §4.2/§6 corrected in place |
| #140 | vcs lock | package-system.md §4.5/§6’s naming half, built: Lock (crates/cancho-vcs/src/lock.rs), name-keyed rather than Manifest’s SigId-keyed, because a consumer’s own import names a dependency, not a hash it does not have yet. cancho vcs lock --store <dep-store> -o <file> <name>... looks a name up in a dependency’s manifest and pins it, refusing an unpublished or ambiguous one. vcs resolve --lock <file> <store-dir> scopes resolution to just those pins instead of a whole store. One prediction corrected on contact: §4.5 said re-locking a name to a different hash should be a refusal; building it found the guarantee was about automatic substitution, and an explicit second vcs lock run is a deliberate re-pin, the same act as cargo update – so it overwrites, and the section is corrected in place to say what actually holds. Verified by hand against every case before writing tests. Still not built: resolve --lock checks one store against one lock, not a program’s dependency graph – no recursion across a closure of stores, no cycle or diamond-conflict detection. The next slice is an import reading a lock at compile time |
| #141 | vcs fetch | package-system.md §6’s own named next slice, built – but the premise it was scoped under turned out wrong. §6 said the next case was import learning to read a lock at compile time; building it found that modules.md §4.2 already means a program is the set of files named on the command line and import already resolves a name against whichever of them declares it, with no search path or manifest lookup in the compiler to extend. So the real next slice needed zero compiler changes – only a place to fetch a verified dependency source to. cancho vcs fetch --lock <file> --store <dep-store> -o <dir> re-verifies every pin the same way vcs resolve --lock does, then writes each distinct verified source to <source_hash>.cho, nothing written unless everything verifies first. cmd_resolve’s inline verification loop is factored into select_locked/verify_selected so resolve --lock and fetch share it rather than duplicate it. Proven end to end, not asserted: fetch_materializes_a_verified_file_a_real_program_can_import publishes a dependency, locks it, fetches it, then builds and runs a second, separate file that imports the fetched file unmodified. docs/package-system.md’s §4.6/§6 corrected in place to record the finding. Still not built: recursive resolution across a closure of stores – resolve --lock/fetch still check one store against one lock, not a program’s full dependency graph; no cycle or diamond-conflict detection |
| #142 | net.sockets, the first real package | Every prior package-resolver slice was tested against fixtures; this is the first real dependency for a real program. examples/serve/serve.cho and examples/results_stub/results_stub.cho had independently declared the same eight extern fns against libc and the same put/put_nat byte helpers since #129 – organic duplication this project’s own “a feature earns its way in when a program asks” rule already justified extracting. Moved to packages/net-sockets/sockets.cho, published to a real checked-in store; both consumers lock it in their own net.lock and import net.sockets instead of duplicating it. Found vcs publish’s only real gap so far: every store published before now held ordinary functions with bodies, and cmd_publish read a declaration’s effects off its lowered IR function unconditionally – an extern fn has no body to lower, so publishing any file with a foreign declaration hit internal: has an identity but no lowered function. Fixed by reading Program::externs when no lowered function matches. Verified end to end: both binaries still build, run, and answer a real HTTP request identically to before the extraction, on both backends. Test fallout from two examples no longer being single-file programs: authority.rs/backends.rs/net.rs now fetch the package fresh (never a checked-in copy) via new helpers in main.rs; net.rs’s network-programs walker gains packages and an updated expected list – 4 programs still ask for inbound socket authority, now across 3 declaring files rather than 4, docs/net.md §5’s matching “Recounted” entry says so. docs/package-system.md’s status header and §6 record the extraction and the fix. Still not built: recursive resolution across a closure of stores |
| #143 | Both remaining “not usable” reasons, re-measured | Asked directly: what would close README.md’s own “not a usable language yet” paragraph, now that #142 landed a real package. Reason 1 (package system) corrected in place – “there is no package system” is stale after four merged PRs; the honest gap is narrower, no manifest and no multi-dependency resolution. Reason 2 (the 71% effect-vocabulary figure) re-measured, not assumed: the working copy was shallow-cloned, so git fetch --unshallow first, then scripts/history.py against the real full history – 165 revisions, 104 read, 61 do not (37%, down from 71% originally and 41% at #85). The load-bearing check was not the percentage but whether the vocabulary still breaks old files: git diff since #120 shows null_ptr/spawn/join/Thread all landed, touching builtin.rs/defs.rs/cancho-types exactly as hash-stability.md §3’s stale “plateau” claim said growth would look like – but every one is edition-gated and additive by its own stated design, and the corpus gained 24 revisions since #85 while only 3 joined the unreadable pile. The vocabulary keeps moving; editions.md’s edition-gating means it no longer reaches backward. docs/hash-stability.md gets a “Re-measured (#142)” entry (§2) and a “Corrected again” entry (§3) with the actual numbers and the --alias/--migrate cross-checks (unchanged: 0 and 23, matching #85); README.md’s paragraph rewritten to match both findings |
| #144 | net.connect, two dependencies at once | Asked directly: can this actually resolve multiple dependencies? Six more examples turned out to duplicate net.sockets’s declarations independently – fetch/, report/, collect/, vsock/, agent_guest/, agent_supervisor/ – and split cleanly on net.md §1’s own line, “the two directions do not meet”: inbound wants net.sockets alone, outbound wants net.sockets plus connect, which stays its own package (net.connect, packages/net-connect/connect.cho) rather than joining it. examples/fetch/fetch.cho migrated onto both – two independent vcs lock/vcs fetch pairs, composed at the same build command line, needing zero new tooling. The question package-system.md §6 had flagged as unbuilt (“recursive resolution across a closure of stores”) turns out to be a different question from “does N direct dependencies work at all” – that one already worked, it just had never been tried against a real program. What’s still actually unbuilt is narrower: a dependency that itself has dependencies, and conflict detection across them. Found a real, separate compiler gap: cancho authority’s foreign_symbols is not reachability-pruned the way Program::funcs is – cancho_ir::lower_all collects every extern fn unconditionally, before pass 2’s reachability walk runs, so importing a package that declares more than a program calls over-names what it reaches. Not a safety bug (over-naming fails closed), but a real precision gap against authority.md’s own claim; documented at docs/authority.md §3 and docs/under-a-grant.md §6, not fixed here – a separately-scoped cancho-ir change. README.md’s own “nothing resolves more than one dependency at a time” claim, written one slice ago, corrected in place |
| #145 | foreign_symbols, reachability-pruned | Closes the gap #144 found and documented but did not fix. Fixed the same way docs/crypto.md §4 fixed the identical problem for statics: a read-only, exhaustive-match walker over every reachable function body (fold::collect_extern_refs/collect_extern_refs_body, mirroring collect_static_refs’s exact shape) collects the Callee::Extern indices actually called, wrapped as cancho_ir::reachable_externs; print_authority filters program.externs by it before building foreign_symbols. Program::externs itself is untouched – nothing renumbers it, so Callee::Extern’s index and both codegen backends, which index into it positionally, needed no change. authority.rs gets a dedicated, package-independent regression test; net.rs’s assertion relaxed in #144 is restored to its original, stricter form. docs/authority.md §3, docs/under-a-grant.md §6, and docs/package-system.md’s status header all move from “found, not fixed” to “fixed” |
| #146 | The other five duplicating examples, migrated too | Finishes what #144 flagged as “candidates, not done here”: report/, vsock/, agent_guest/ now import net.sockets and net.connect; collect/, agent_supervisor/ now import net.sockets alone. Nine files’ worth of duplicated extern fn declarations collapse into the two real packages – the same lock/fetch/build pattern proven on serve/, results_stub/, and fetch/, run five more times with no new tooling. net.rs’s the_network_programs_are_counted recounts to one inbound declaring file (packages/net-sockets/sockets.cho) and two outbound (packages/net-connect/connect.cho, examples/tls_client/socket.cho – still on edition 1, unmigrated for the reason its own note gives); backends.rs’s LLVM-collision guard now fetches each program’s dependencies before building. docs/package-system.md §6’s actual remaining gap – a dependency that is itself a dependency, a true closure of stores – is unchanged: this was convergence onto already-real packages, not new composition |
| #147 | agent.wire, the third real package | examples/vsock/vsock.cho and examples/agent_guest/agent_guest.cho each declared the same five pure functions – an AgentViewMsg decoder, byte-for-byte – the same “two real askers” bar net.sockets cleared first, but the first package with no extern fn in it at all. Confirms vcs publish/vcs lock/vcs fetch are indifferent to whether a declaration is a libc binding or ordinary Lex with a body – a package is just a store. Found the one real constraint that shape has: vcs publish never makes --std available (cmd_publish parses with with_std: false unconditionally), so the package inlines its own digit check rather than importing std.bytes, the same workaround net.sockets’s own put/put_nat already used. The JSON escaper on the encoding side of the same wire protocol (vsock.cho’s append_json_escaped, agent_supervisor.cho’s put_escaped) stays unextracted: same rule, two different signatures (one needs std.buffer/Heap, the other a fixed slice), so bundling them would guess at a shape neither file asked for |
| #148 | Closure resolution, designed against a real case | examples/collect/collect.cho and examples/agent_supervisor/agent_supervisor.cho duplicate a fourth pair, content_length_of/read_request, but the package that would hold them needs net.sockets itself – confirmed directly, not assumed: publishing a file that imports net.sockets today refuses with “no module net.sockets in this program.” The real transitive-dependency case §6 had pointed at speculatively (via --std, which never had a reason to force it) for three PRs, now concrete. New §4.6 designs the fix: a store-carried requires/*.json, vcs publish --requires <lock>:<dep-store> to write it, a recursive walk in vcs resolve/vcs fetch with cycle and diamond-conflict refusal. A follow-up commit to this same PR corrected one claim in the design itself, found while implementing it: cancho-id::identify() is not resolution-independent – a call’s contribution to its caller’s body hash is the resolved callee’s own signature when the callee resolves, so computing identity without the dependency present would record the wrong hash and refuse a body that never moved. Design only; implementation in #149 |
| #149 | Closure resolution, built | Builds #148’s design. Requirement { store, lock } (crates/cancho-vcs/src/requirement.rs), one requires/*.json per distinct dependency store; vcs publish --requires <lock>:<dep-store> (repeatable) resolves and verifies each dependency, merges its source into the file’s own for the soundness gate and for check_candidate, and computes identify() from that same merged AST (per #148’s correction), filtered by this file’s own names so a dependency’s declarations are never republished. resolve_closure/resolve_own_requirements walk a store’s own requires/ recursively, threading a visiting stack (cycle refusal) and a pinned map (diamond refusal: the same (store, name) pinned to two different heads by two different paths) through the whole walk rather than resetting per level. verify_selected gains an extra_context parameter, a no-op for every store with no dependency of its own – net.sockets/net.connect/agent.wire unaffected. vcs fetch writes the whole closure to -o, not just the directly-locked store’s own. Verified against a real dependency (net.sockets, a prototype http.request-shaped file) before writing the synthetic-package conformance tests that ship with this PR; packages/http-request/ itself, the package this gap was found from, is the next slice |
| #150 | http.request, the real motivating case | packages/http-request/ (request.cho), the first package that itself depends on a package: content_length_of/read_request move out of collect.cho/agent_supervisor.cho into one file, published with --requires <net.lock>:packages/net-sockets/.cancho-vcs. Both examples now import http.request instead of duplicating the pair, and drop their separate net.sockets lock entirely – a store is always exactly one published file, so fetching any of http.request’s own declarations transitively materializes the whole net-sockets.cho file too. Building it against the real, non-synthetic pair found a second real bug in #148/#149’s own design: Requirement.store was read back as a bare path, silently interpreted against whatever working directory the reading process happened to have rather than the one it was recorded relative to at publish time – invisible against the mechanism’s own tests (their stores are always absolute paths under one scratch directory), but real once cargo test’s own integration binaries (which run with their crate directory as cwd, not the repo root) hit a genuine multi-store closure. Fixed by recording Requirement.store relative to the depending store’s own directory (relative_from, computed lexically at publish time, never touching the filesystem) and resolving it by joining against that store’s own already-resolved path rather than reading it bare. §4.6 and its status header both carry the correction |
| #151 | ROADMAP.md: the row for #150 | Docs only – adds the row above, following the convention of adding it once the PR number is known |
| #152 | Correcting the stale “no closure of stores” claim | README.md’s “not a usable language yet” paragraph and docs/README.md’s package-system.md index row both still said a dependency’s own dependencies do not resolve – true when written, false since #149/#150. Corrected in place rather than left to rot, the same convention this file documents for this repository’s own README |
| #153 | net.connect extended, http.response, the fifth package | Found by hashing every function body across examples/, packages/, std/ and benches/ and grouping the ones that collide, rather than rereading files by eye: fetch.cho, report.cho and agent_guest.cho each declare the same six functions byte-for-byte. octets_of/port_of/address/connect_to are generic to any outbound program and join net.connect itself – growing an already-published store with more declarations is the same act as its first publish, just a second vcs publish against the same --store, unchanged entries skipped. send_all/status_of are HTTP-specific and become packages/http-response/ (response.cho), the client-side mirror of http.request. Both now import net.sockets too, its third and fourth real consumer. Found one thing the design had not foreseen: two sibling packages transitively requiring the same dependency means fetching each into its own output directory – every earlier multi-package example’s own shape – now fetches it twice, under two different paths, which build/check refuse as a duplicate declaration; fixed by fetching into one shared output directory instead, which also fixed the same latent collision in vsock.cho’s own manual fetch instructions. fetch.cho/report.cho/agent_guest.cho all verified end to end against real peers over real sockets, not just build success |
| #154 | ROADMAP.md rows for #151-#153, and re-measuring the gap to C on --backend llvm | Started as the routine ROADMAP-row follow-up; grew when a direct question – is cancho still 1.17x-2.58x slower than C under the now-default LLVM backend – turned out to have no real answer on file. scripts/game.py/scripts/three.py pass no --backend flag, by design, so they measure whichever backend is the default; that stopped being Cranelift the day #127 landed, and nobody had re-read what they now report. Three runs of scripts/game.py --rounds 9 plus two of scripts/three.py --rounds 9: fannkuch ~1.0x (was 1.32x), spectral 1.27x-1.46x (was 2.58x), binarytrees 1.20x-1.34x (was 1.17x), fasta 0.89x-0.90x, still faster than C (was 0.54x), revcomp too noisy on this container to pin down past ~1.0x-1.6x, mandelbrot 0.95x (was 1.69x), sieve 0.93x-0.95x (was 1.56x) – both of against-c-and-rust.md’s own kernels now faster than C, not merely closer to it. The high end moved exactly as benchmarks-game.md §4’s own falsifier predicted; fasta moved the wrong direction for a naive “faster backend” story even though fasta.c is unchanged, and is left open rather than explained. docs/benchmarks-game.md §8, docs/against-c-and-rust.md §2.1, docs/ROADMAP.md’s own pre-#127 “What is next” essay (superseded in place, not rewritten), docs/first-page.md and benches/game/README.md all corrected. Also fixed cancho --help’s --backend text, found while tracing this down: it still said “default: cranelift” and “opt-in” for llvm, both false since #127 – the actual default was already correct, only the string had drifted |
| #156 | Correcting the stale sqrt passage in benchmarks-game.md | Scoped “std.math over floats” – benchmarks-game.md §5’s own Open row – as a candidate next ticket and found it already done: #62 made sqrt a builtin four months earlier, and spectral.cho’s own header has said so ever since. #62 never touched benchmarks-game.md itself, so §3’s “sqrt” paragraph and §5’s Open row kept describing the pre-#62 state – the n-body row two lines below the stale one already assumed sqrt existed, so the table had drifted from itself, not just from the code. Corrected in place; confirmed cosmetic, not a measurement bug, since the call sits once outside the O(n^2) loop the benchmark actually measures |
| #157 | cancho introspect/skill, imported from lex-lang’s ACLI integration | lex-lang’s crates/lex-cli/src/acli.rs registers every subcommand with the acli SDK so its CLI is self-describing; cancho had only a hand-maintained USAGE string. One Vec<CommandInfo> registration (src/acli.rs) now backs both cancho introspect [--output json] (the full command tree) and cancho skill (a generated agentskills.io SKILL.md), checked against the dispatch table itself by a new conformance test so the two cannot drift apart the way benchmarks-game.md just had. Found a bug of the same shape while building it: the SDK always renders an option with a -- prefix, but -o/-l/-L are single-dash real flags – registering them as options would have taught an agent to write --o/--l/--L and get refused. Fixed by registering them as arguments instead, which render bare. A second inaccuracy – the generated skill’s fixed “Exit codes”/“Output format” section, acli 0.5’s own generic template, which doesn’t match cancho’s real 0/1/2/3 contract – has no override hook in this SDK version and is documented as a caveat rather than silently shipped |
| #192 | Packages across repositories | package-system.md §7, steps 1 and 2. A Lock may carry an origin (git URL, full commit hash, path); vcs lock --git, vcs fetch and vcs resolve --lock fetch the commit into a cache keyed by it and hold it to the same recheck as a local store, so the transport carries no trust and a lock never holds a branch name. A requirement whose lock has an origin is read through it (a package can require one in another repository). vcs publish --dir publishes a library of several files in dependency order, byte-deterministically, and static declarations now publish (cancho-log’s crc could not). Found by trying it on a real library: a clean checkout of cancho-hooks plus the compiler builds from two locks and passes its idempotency and chaos suites. 21 mutants of the new code, 20 killed, one (a rev-parse behind git’s own hashing) unverified. Not built: a project file, a checked compiler pin, prebuilt compiler releases, the authority diff on update |
| #193 | The project file | package-system.md §8, §7.5 steps 3 and 4. cancho.toml names the compiler a project was written for, its dependencies (each a git URL, a full commit hash and the store inside) and its programs; cancho install fetches and checks the dependencies, cancho add <name> <url> pins one (a --ref is resolved once and the hash written; the file is put back if the install fails), and cancho build with no files installs and then builds. cancho --version now reports the commit it was built from, and a project that names another compiler is refused, naming both: the toolchain floor lex-lang wrote for a long time before anything read it (#803). cancho-hooks runs on it, with one compiler pin instead of two. 23 mutants, 22 killed, one unverified (the branch for a compiler that does not know its revision). Also measured, not built: a cancho binary is a native executable linked only to libc (169 KB for the service; 1.2 MB static with a linker that adds -static); no Docker daemon was available, so no image was built (§8.6). Not built: version ranges, test in a project, a registry, prebuilt compiler releases |
| #194 | cancho test in a project | package-system.md §8.8. [[test]] sets in cancho.toml (sources, std) and cancho test with no files: install, then run every set against the project’s dependencies, --test <name> one; a failing set does not stop the rest and the exit code is the first failure’s. With files it is the runner it was. 10 mutants, 10 killed. Also found: the compiler runs clang and cc at build time, so a released binary needs a C toolchain where it runs |
| #195 | Prebuilt compilers | package-system.md §9. scripts/package-release.sh (a tarball and checksum named for the full commit, read from the binary; a dirty or unstamped binary is refused), scripts/install.sh (checksum and reported commit both checked before anything is installed), .github/workflows/release.yml (linux-x86_64 on ubuntu-22.04 for glibc, darwin-aarch64; install and build hello from the packaged asset; a release only on an explicit publish, tagged with the commit). Six conformance tests on a stand-in compiler. Its first run on GitHub found three faults, fixed here (macOS asset naming in the tests, an exit code, and clang 14 on 22.04). Nothing is published. |
| #212 | std.chacha20: ChaCha20-Poly1305 for the pure TLS client | #199, the first slice of #197 (a TLS 1.3 client with no C library). Its own file, so #198 can still move it to a package. Every refusal tagged; open checks the whole tag in constant time before it writes anything; arithmetic on secrets is wrapping_*, and the object code shows no conditional jump in the secret path except traps on public data (the Poly1305 select is cmov). 28 RFC 8439 vectors on both backends, all 325 Wycheproof cases, 1,136 one-bit flips, 100,000 random cases against OpenSSL with 0 differences, 18 of 18 mutants killed. 135 MB/s on 16 KiB against OpenSSL’s 2.29 GB/s. Found: the mutant script was weaker than the test it mirrored (one survivor until it checked the exact tag), and the duplication check found the little-endian store already in examples/vsock, now std.bytes.store_le32. Not done: a timing test (#208), guaranteed key erasure (no primitive for it), the independent review (#209) |
| #229 | SHA-384, std.hmac and std.hkdf | #201, the TLS 1.3 key schedule’s hash, HMAC and HKDF (RFC 5869, RFC 8446 §7.1), each length rule refused with its own tag. Found: every hash in std trapped on a 64 KiB message (the padded copy went into one arena), and so did ed25519.sign, which crypto.md §4 and sha512.md §3 had claimed otherwise; SHA-2 now streams over a caller’s state, and a 64-byte SHA-256 costs 0.99 µs instead of 10.7. SHA-256’s checked adds and not32’s subtraction were overflow tests on a hashed key’s words; now they are not. CAVP (SHA-384 short, long, Monte Carlo), RFC 4231, RFC 5869, three TLS 1.3 key schedules, 517 Wycheproof cases, 800,000 differential checks with 0 differences, 19 of 19 mutants killed (one survived every fixed vector until the streamed cases). Not done: the cancho-hooks switch, which needs this merged; RFC 8448’s application secrets |
| #230 | A TLS 1.3 client with no C library: the design | #198, before any protocol code. Primitives with vectors in std and protocol in packages, with both costs written down; a sans-io API (feed/take/send/recv/event over engine-owned slots) that the OpenSSL backend’s memory BIOs already fit and that takes no capability; TLS 1.3 with ChaCha20-Poly1305 and X25519 only; a threat model naming the secret path; SAN-only name matching; a refusal tag per actionable failure mapped to X509_V_ERR_*. Found: the language has no randomness source (the caller seeds a ChaCha20 DRBG from /dev/urandom through Fs, as hooks’ SCRAM nonce already does), and three of the OpenSSL error numbers in the first draft were wrong until checked against x509_vfy.h. Eight questions left for a person |
| #231 | X25519, and the field it shares with Ed25519 | #200. std.field25519 (16-bit limbs, wrapping, a masked cswap, caller-owned scratch) under std.x25519 and, replacing its bit-serial field, std.ed25519, now 31 to 35 times faster. RFC 7748 on both backends, 518 Wycheproof X25519 and 151 Ed25519 cases, 24,000 differential checks with 0 differences, 19 of 21 mutants killed (one equivalent, one unreachable). Checked with Valgrind, not only by reading: with the scalar marked secret Memcheck reports nothing for X25519, and 252 errors for a planted branch. Found: Wycheproof caught two older Ed25519 bugs, a short signature trapping verify and an R with x = 0 and the sign bit accepted; both fixed |
| #232 | Checked output: flush_out | #215, gap L1 of the agent toolbox: a failed write to standard output was invisible (write_bytes answers the buffered count; libc flushes at exit and ignores the result). flush_out(io) -> [io_write] Done, edition 5, on both backends. Found: fflush alone is not enough – glibc answers 0 once an earlier failure has emptied the buffer – so it is fflush then ferror, and an earlier failure answers EIO. /dev/full, a closed descriptor, an earlier failure, a pipe and /dev/null each checked from outside; seven mutants killed |
| #233 | packages/x509: strict DER and X.509 v3 certificates | #202. A package, importing nothing: every TLV strict DER, a 40-word view of offsets into the certificate (nothing copied), 19 refusal codes each with a tag, an unknown critical extension refused, two leniencies accepted and flagged. All 128 system roots equal pyca/cryptography’s reading field for field; 30,379 x509-limbo certificates with no trap and every refusal real when read; 36 damaged certificates, one tag each; 1,000,000 fuzz rounds on both backends with no trap. Found: a v2 certificate reported as v3 (so v2 with extensions passed), one oversized PEM block ending a whole bundle, and two comments about the system roots that measuring proved wrong. Not done: signatures, chains and names (#206), publishing (#205), the review (#209) |
| #234 | std.bigmod and std.rsa: RSA signature verification | #203. Montgomery arithmetic on 30-bit limbs to 4,096 bits (checked arithmetic, no wrapping_*: an overflow would be a bug, so it traps), and RSASSA-PKCS1-v1_5 (the expected block built and compared whole, never parsed) and RSASSA-PSS (explicit MGF1 hash and salt length) verification, each refusal tagged. Wycheproof: 694 valid verify, 2,569 invalid refused; NIST SigVer all right; 100,000 pow_mod cases against Python and 2,000 signatures against OpenSSL with 0 differences; 23 of 23 mutants killed. 2,800 RSA-2048 verifications a second on LLVM, 18 times slower than OpenSSL. Found: a limb-loading bug the first differential caught; a lazy-reduction mutant no random input could kill, until prime-power moduli were added; two equivalent mutants. Not done: SHA-1/224, signing, the review (#209) |
| #235 | Zero-filled heap slices: calloc | #214’s L-side follow-up. A box_slice whose fill is a literal zero (0, false, 0.0, byte_of(0)) is calloc and no fill loop, on both backends; anything else, -0.0 and a zero computed at run time included, keeps malloc and the loop. cancho-tools’ jsonq on 16 MiB: 0.46 s to 0.15 s, peak RSS 427,540 KB to 88,888 KB. Zero kinds read back zero in freshly freed memory full of nines; a 512 MiB zeroed slice stays under 64 MiB resident. 7 of 8 mutants killed, one equivalent (clang deletes the zero stores) |
| #239 | std.ecdsa: ECDSA verification on P-256 and P-384 | #204. DER (strict) and raw signatures over any digest length, Jacobian points with the formulas’ uncovered cases handled explicitly, Shamir’s trick; on a new register API in std.bigmod, so std keeps one Montgomery multiplication. Curve constants printed from OpenSSL and checked by a script. Wycheproof: 1,370 valid verify, 1,728 invalid refused; NIST 90 of 90; 4,000 OpenSSL cases and 100,000 register operations with 0 differences; 21 of 21 mutants killed. P-256 675/s on LLVM, 20 times slower than OpenSSL’s hand-tuned code. Found: a contract of load_reduced that no vector could see, since n acts as 0 under Montgomery; now tested directly. Not done: P-521, prime-specific reduction, the review (#209) |
| #240 | Copying between slices: copy_into | #214’s L-side follow-up. copy_into(dst, src), edition 5: all of src onto the front of dst as one memmove (the checker lets a unique and a shared view of one buffer meet in one call, so overlap is real; memory-moves.md’s claim otherwise corrected in place), trapping unless it fits. std.buffer’s reserve and append use it: 640 MiB of 4 KiB appends from 2.00 s (Cranelift) and 0.47 s (LLVM) to 0.026 s on both. 11 of 12 mutants killed; memcpy for memmove is equivalent on glibc and documented |
| #241 | Finding a byte: index_of_byte | #214’s L-side follow-up. index_of_byte(text, b), edition 5: the first b as one memchr, -1 when absent. std.bytes’ find (candidates by the needle’s first byte) and count_byte use it: on 64 MiB, find 2.82 s to 0.61 s (Cranelift) and 0.72 s to 0.15 s (LLVM), count_byte 3.4x and 2.3x; a needle whose first byte is common (a space) regresses on LLVM, 0.46 s to 0.66 s, measured and kept. Found: Cranelift refused a program that declares memchr itself as a compiler bug; the byte is now passed at 64 bits. 11 of 12 mutants killed, one equivalent |
| #242 | packages/tls: the design | #205, 1 of 3, before any protocol code. Three PRs (this design; the protocol offline; the engine and the network); four modules under 2,000 lines; a slot as a fixed stride; the state machine; certificates accepted only when pinned until #206 (a client that skipped chain checks would trust anyone). Found: RFC 8448’s Simple 1-RTT trace is AES-128-GCM, so a ChaCha20-only client cannot replay it, and the RFC is unreachable from here; the key schedule comes from s2n-tls’s transcription of it, every value reproduced in Python (the application secrets docs/hkdf.md lacked included), and the byte-for-byte gate is a recorded ChaCha20 handshake against tlslite-ng with fixed randomness. A question for a person: whether that meets the issue’s gate |
| #245 | packages/tls: records, messages, the client | #205, 2 of 3: the protocol with no network. tls_record, tls_message and tls_client (each under 2,000 lines): ChaCha20-Poly1305 records, strict parsers, the state machine, transcript and key schedule, pinned certificates until #206, RSA-PSS/ECDSA/Ed25519 CertificateVerify, KeyUpdate. Two handshakes recorded against tlslite-ng replay byte for byte on both backends, also one byte at a time and coalesced; 15 crafted ServerHellos and records each refused with its own tag; the record layer matches pyca on 4,000 records. Found: RFC 8448’s application secrets (from s2n-tls; hkdf.md corrected), the slot is 179 KiB not 100 (tls-pure.md corrected), and two bugs caught reading the code before it ran. Not done: the engine, live servers as tests, the misbehaving server, mutants (PR 3) |
| #246 | packages/tls: the engine and the network | #205, 3 of 3. tls.cho, the engine of tls-pure.md §2.2 (slots, pins, a fast-key-erasure DRBG), and tls_many, up to 256 connections on one thread through the Poller. 64 connections at once against Python ssl (five key types), openssl s_server and tlslite-ng, one byte and 64 KiB a read, all ok; a server closing without close_notify fails every one tls-peer-closed. A deterministic lying server: 29 connections replayed byte for byte on both backends, every row of §6.3 refused with its tag and alert; 64 recorded connections served from Rust in cargo test. 22 of 22 mutants killed. Found: the slot-overlap mutant survived until the poller read once a wakeup and the request filled a record (the first poller never interleaved one-byte reads); §4’s change_cipher_spec rule was stricter than the code and the RFC (corrected); §8 claimed the end of a connection erased the slot and only drop did (now true); the API’s three differences are noted in tls-pure.md §2.2 |
| #248 | packages/x509: verifying a server’s chain, the design | #206, 1 of 3, before any code. Two modules beside x509.cho (x509_names, x509_verify); an API over the sent chain’s ranges, so packages/tls verifies in place; depth-first path building with backtracking, signatures first, no certificate twice, a 64-signature budget; what leaf, intermediate and root are each checked for (the root’s cA, pathLen and constraints, not its signature or dates); dNSName and iPAddress name constraints, any other type refused. Measured: 9,572 of x509-limbo’s 9,802 cases are BetterTLS name-constraint cases, and the 14 online:: real chains all end at roots in this machine’s 128-root bundle, so the issue’s saved-chain gate needs no network |
| #249 | packages/x509: verifying a server’s chain | #206, 2 of 3. x509_names (the host, SAN matching, dNSName/iPAddress name constraints) and x509_verify (a store that skips bad roots; depth-first path building with backtracking, AKI/SKI and a 64-signature budget; RSA PKCS#1 v1.5 and PSS, ECDSA, Ed25519). x509-limbo: 9,743 of 9,802 pass, every BetterTLS case among them; 39 disagreements, each checked against openssl verify and listed, 5 of them accepted here and refused by OpenSSL only for its self-issued-anchor rule. An OpenSSL CLI matrix of 33 cases, none accepted that OpenSSL refuses; limbo’s 14 real chains against the 128 system roots, 84 checks; 22 of 22 mutants killed. Found: tls-pure.md §5.1’s claim that OpenSSL accepts an expired root was false (it refuses one; corrected, and roots’ dates are checked); AKI/SKI matching, self-issued depth and five RFC 5280 MUSTs added because limbo showed them |
| #250 | Directory handles: opening beneath a directory, following no link | #227 slice 1 (gap L6). Edition 6 Dir: open_dir (the anchor, open_read’s prefix check), dir_enter and dir_open_read (one component each, openat with O_NOFOLLOW; empty, ., .., /, NUL and over-long names are EINVAL with no call), dir_close; label dir_read, discharged by an owned Fs; std.dirs walks a whole path. A link to a file or directory outside is refused on both backends; 22 of 22 mutants killed. Found: O_NOFOLLOW does nothing about .. (a per-component walk opened ../outside in the probe). Slices 2 (create, rename, remove beneath) and 3 (cancho-tools, M8 flips) not done |
| #253 | packages/tls verifies the server’s chain | #206, 3 of 3. The pins are gone: tls.trust loads roots through x509_verify.store_load, and Certificate is verified against them, the host and clock_unix_ms; the verifier’s tags are TLS codes with RFC 8446 §6.2’s alerts. Every recording made again with a CA, still byte for byte; live, 64 connections at once against Python ssl, openssl s_server and tlslite-ng with CA-issued leaves all succeed, and a wrong host or another CA’s root fails every one with its own tag; 24 of 24 mutants killed. Found: tls_many first passed the monotonic clock_ms, so every certificate was “not yet valid”; and two test CAs sharing a name are one issuer by name, so the “another CA” test said x509-bad-signature, correctly, until each had its own |
| #254 | Directory handles slice 2: writing beneath a directory | #227 slice 2. Edition 6 dir_open_new (O_EXCL), dir_open_append, dir_rename (renameat, one directory), dir_remove (unlinkat) and dir_sync, each name one component, label dir_write discharged by an owned Fs. No link is ever written through: create over a link (dangling too) is EEXIST, append through one ELOOP, remove takes the link and leaves its target. openat’s variadic mode is declared variadic on LLVM and shaped like fcntl’s on Cranelift for Apple AArch64; a created file is 0644. 14 of 14 mutants killed. Slice 3 (cancho-tools, M8 flips) not done |
| #261 | TLS parity with the OpenSSL backend: the design | #207, 1 of 5. The issue’s number (receivers needing 1.2 or AES-GCM) cannot be measured from here, and the maintainer’s requirement replaces it: equivalence with the OpenSSL backend, scoped to AEAD parity. Measured: OpenSSL 3.0.13’s ClientHello offers 31 suites, 10 groups, one X25519 share and the extended master secret. The plan: constant-time bitsliced AES-GCM, constant-time P-256/P-384 key exchange, TLS 1.3’s AES-GCM suites and HelloRetryRequest, TLS 1.2 with the six ECDHE AEAD suites and EMS required; what stays different (DHE, CBC, static RSA, X448, P-521, FFDHE, resumption) is listed with its tag. Found: RFC 7905, which the issue names for vectors, has none |
| #262 | Constant-time AES-GCM, std.aes and std.gcm | #207, 2 of 5. Bitsliced AES-128/256 (BearSSL’s aes_ct) and GHASH by masked integer multiplies (ghash_ctmul32), with std.chacha20’s seal/open. Checked: FIPS 197, 1,500 NIST CAVP GCM cases on both backends, every Wycheproof case, 10,000 random cases against OpenSSL with 0 differences, 24 of 24 mutants killed, no secret-dependent branch on either backend, and a dudect Welch t-test at 10^6 samples per test with max |t| 3.13. Measured: 32.6 MB/s on 16 KiB records, a quarter of ChaCha20-Poly1305 and 1/170 of OpenSSL with AES-NI. Found: nested regions cost 8 brk calls per seal (23 µs for 64 bytes, now 5 µs with one region); ghash_ctmul64, as the design said, cannot be written with a checked 64-bit int (corrected in place); and the first timing harness reported a false leak (t = 174 for plain XORs) from decoding input just before each timed call |
| #264 | std.ecdh: P-256 and P-384 key exchange, and value_barrier | #207, 3 of 5. Complete RCB formulas, a 4-bit window table read under masks, the peer’s point checked first, and std.bigmod’s reductions made branchless. Checked: every Wycheproof ecpoint case, NIST’s KAS validity cases on both backends, 4,200 checks against OpenSSL with 0 differences, 16 of 16 mutants. Found: clang -O2 turned the reduction’s and the table select’s masks back into branches on the secret ( |
| #265 | packages/tls: AES-GCM suites and HelloRetryRequest | #207, 4 of 5. The ClientHello offers all three TLS 1.3 suites and X25519, P-256 and P-384; records seal under the suite’s AEAD; the key schedule runs on SHA-256 or SHA-384, both transcripts kept until the suite is known; one HelloRetryRequest to P-256 or P-384 is followed with std.ecdh, its scalar drawn by HKDF from the X25519 secret so the caller’s entropy is unchanged. Checked: five re-recorded tlslite-ng traces (two through a retry), 41 lying-server cases (14 new), RFC 8448’s AES-128-GCM flight opened to its plaintext, every suite against every group on openssl s_server, 38 mutants killed. Found: the design’s “existing traces replay unchanged” was false, since the ClientHello itself changed (corrected in place); Python’s ssl now negotiates AES-256-GCM with this client; and the ServerHello’s group check is defence in depth, its mutant equivalent |
| #266 | packages/tls: TLS 1.2 with ECDHE, AEAD suites and EMS required | #207, 5 of 5. TLS 1.2 ECDHE-RSA and ECDHE-ECDSA with AES-128-GCM, AES-256-GCM and ChaCha20-Poly1305, offered beside TLS 1.3 as OpenSSL’s ClientHello does; the extended master secret required (tls-extended-master-secret) and renegotiation refused (tls-renegotiation); the ServerKeyExchange signature checked against the leaf’s key type. Checked: six openssl s_server TLS 1.2 traces, one per suite; 63 lying-server cases (22 new for 1.2); the PRF against Python on 1,000 inputs with 0 differences; 20,000 fuzzed server flights with no trap; 59 mutants killed; the live matrix. Found: the design’s EMS tag and its explicit-nonce case were wrong, corrected in place |
| #267 | The assembled TLS stack under test: the design | #208, 1 of 4. How each of #208’s bars is met: AFL++ through the LLVM backend’s CLANG and CC, so the code fuzzed is the code shipped with no compiler change; five harnesses fed only the bytes an attacker controls; a budget of 100 million executions from measured rates; the differential and interop matrices over the servers measured installable; dudect for X25519 and ChaCha20-Poly1305; resource bounds under a hostile server; the CI job. Found: the existing text driver, fuzzed for a minute, gave 107 trapping inputs, all in the driver’s hex decoding or its test-only record operations given keys of the wrong length, none reachable from a server’s bytes, so the harnesses read raw bytes |
| #270 | packages/tls: interop with eight servers, and the differential against OpenSSL | #208, 3 of 4. Interop: Go crypto/tls, rustls, nginx, GnuTLS, wolfSSL, BoringSSL, mbedTLS and Botan, every certificate type and suite each can be told to use, 126 rows, all ok. Differential: 21 handshakes against openssl s_server and rustls give this client and openssl s_client the same version, suite, group and signature scheme; the lying server’s 66 cases against s_client agree but for 16 alerts RFC 8446 leaves open and 6 differences on purpose, each named with why. Found: every TLS 1.2 connection to nginx failed, since its ServerHello’s empty server_name (RFC 6066 §3) was refused; and the downgrade sentinels were refused in a TLS 1.3 ServerHello, where RFC 8446 §4.1.3 does not check them. Both fixed, with tests and two mutants (61 of 61 killed) |
| #272 | packages/tls: fuzzing, timing and hostile-server bounds | #208, 2 and 4 of 4. Fuzzing: five AFL++ harnesses fed only where a server’s bytes go (DER, chain, messages, the whole client, and the TLS 1.3 flight past the AEAD), built through the LLVM backend’s CLANG and CC so the code fuzzed is the code shipped: 176,224,422 executions, 17 core-hours, no crash and no hang, plus 20.8 million in an interrupted first run; the minimised corpus (1,387 inputs) replays on both backends in conformance/tls_fuzz.rs, and CI fuzzes each harness for 2 minutes. Timing (dudect, 20,000 samples, both backends, an Apple M4 Max): X25519 and ChaCha20-Poly1305 pass; ECDH with scalar 1 fails on both backends and AES-GCM’s data tests fail on Cranelift, with equal instruction counts for both classes, and Arm’s DIT bit removes the ECDH failure and most of AES-GCM’s – so ecdh.md §3 and tls-parity.md §3.1, which said they pass, are corrected in place. Hostile server: 6,140 KiB mapped in every case, at most 0.055 s of CPU a connection (10,000 tickets), 0.003 s for a 31 s stall |
| #281 | The pure TLS backend in cancho-hooks | #210. The design, then what building it corrected: tls-pure.md §2.1’s “no adapter, switch by dependency” was false (hooks’ tls.cho owns the socket I/O, the engine owns its slots, and no single source can carry both capability rows: no effect polymorphism), and §9’s “no ffi at all” cannot hold for hooks, which also holds libc; both corrected in place. Built in cancho-hooks#42 as a second binary from a generated source (exact-match replacements, line numbers kept), a project of its own. The https tests on both builds: 97 checks, 96 the same, 1 different on purpose (SSL_CERT_FILE: cancho reads no environment). Foreign symbols 34 to 2. 17 adapter mutants, 14 killed, 3 argued. A full handshake costs 4 to 7 times OpenSSL’s CPU, no resumption, about 106 KiB a held handshake against 58. The default stays OpenSSL until #209. Found an LLVM codegen failure for fs_read as an operand |
| #286 | TLS 1.3 session resumption | Designed and built. The pure client’s 2.8 ms handshake is two X25519 and two ECDSA verifications (0.57 and 0.82 ms each, measured); PSK with (EC)DHE removes the verifications and keeps forward secrecy. Eight rules bound the hazard that a resumed session skips verification: the same name, the same trust store, before the leaf’s notAfter, an hour from the verification, single use, no 0-RTT, TLS 1.3 only, every server choice checked (tls-illegal-psk). Building it found two bugs: a ticket saved after a clean close had lost its host name (overwritten with the keys), so nothing resumed; and Go’s and rustls’s servers send no ticket to a client that does not advertise psk_key_exchange_modes (RFC 8446 §4.2.9), which OpenSSL, nginx, GnuTLS and wolfSSL do anyway (tls.set_resumption). Resumes against OpenSSL, Go, rustls, wolfSSL, nginx and GnuTLS; 11 lying-server cases (78), 12 engine-rule cases, 88 mutants (87 killed, 1 argued equivalent), 8.5 M fuzz executions. Measured: a resumed connection costs half a full one’s CPU (1.63 against 3.26 ms; the saving is the two verifications to the hundredth) |
| #338 | std.ecdsa_sign: ECDSA P-256 signing in constant time, and P-256 keys | Step 1 of the TLS server (tls-server.md §8). k·G on std.ecdh’s ladder, s in std.bigmod’s masked registers, RFC 6979’s nonce hedged with 32 bytes of the caller’s randomness, the signature verified before it is given out; x509_key reads PKCS#8 and SEC 1 PEM (P-256 only, base64 decoded without branching on key characters) and matches a key to its certificate. Checked: RFC 6979 A.2.5 on both backends; 10,000 signatures accepted by OpenSSL 3.0.13 and std.ecdsa, all 10,000 bit-flipped refused by both; 10,000 equal byte for byte to RFC 6979 in Python; 100 openssl genpkey keys in both formats; 43 key files, every tag; 37 of 37 mutants. Timing (dudect, 10^6 a test, i7-1260P, LLVM): max |
| #339 | packages/tls: the TLS 1.3 server | Step 2 of the TLS server. One Engine with a role: open_server, add_identity (PEM chain and P-256 key, checked), replace_identity, set_alpn, serve; three suites (AES-128-GCM first with AES instructions), X25519, P-256, P-384 with one stateful HelloRetryRequest, SNI over 16 identities, ALPN, middlebox compatibility, early data skipped to 16 KiB, every refusal tagged; the signer called in one place and checked before sending. Checked: interop 88 of 88 (OpenSSL, curl, Go, wolfSSL, mosquitto pub and sub; suites, groups, retries, SNI, ALPN); a lying client of 110 cases replayed on both backends; against openssl s_server on 72 ClientHellos, 59 agree, 7 alert only, 6 documented; 12.9 M AFL++ executions over two new harnesses and 20,000 mutated connections, 0 crashes; 71 of 71 mutants. Measured: 3.5 to 4 ms of server CPU a handshake on X25519 or P-256, 6 ms on P-384, 17 to 20 times OpenSSL’s. Found: the design’s legacy_version rule breaks RFC 8446 §4.2.1 (OpenSSL disagreed); accept is a builtin’s name; tls_mutants.py stale on main since #334 |
| #346 | examples/tls_echo: the TLS server to copy | Step 3 of the TLS server. A non-blocking echo over std.conns and one Poller, the engine between each socket and the echo, no foreign code (report bounded and pinned). Certificates read beneath a Dir opened once, so the loop and the reload hold dir_read and never the Fs; handshakes bounded in progress and per second, the excess delayed in a queue rather than refused, a deadline per phase, close_notify when idle; SIGHUP reloads with replace_identity, SIGTERM sends close_notify to all. Checked: conformance/tls_echo.rs (64 connections through tls_many, reload taken and refused, stop) and scripts/tls_echo_test.py (OpenSSL over 3 suites × 3 groups and HRR, 200 concurrent Python ssl connections, reload by serial, the bounds, idle, stop), 8 of 8 on linux-aarch64 and macOS; 8 of 8 mutants. Measured: 3.0 ms of CPU a handshake on the M4 (330 to 340 a second a core), 5.5 ms on CI’s x86-64 (the first x86-64 figure), so the default rate is 100 a second, not 200: at 200 the bound sat above what an x86-64 core can do. Found: step 2’s 3.9 ms included 0.9 ms of tls_serve’s loop (§6 corrected); event says closed before the server’s close_notify is queued; http.server cannot take bytes from anything but its own sockets (http-server.md §11 proposes the change) |
| #360 | http.server byte-fed mode, and examples/https_hello | docs/http-server.md §11, built. A server opened with open_bytes has no sockets and no listener: attach, room, input, end_input, ready, output, closing, detach, with the clock an argument, one parser and one back-pressure rule shared with the socket path (the 21 api and 9 http_server tests pass unchanged); stream for an answer larger than the output room, sent as the connection takes it. examples/https_hello puts it behind packages/tls: keep-alive, pipelining, JSON echo, a 64 MiB streamed body, a client that stops reading stops being read, certificate reload and the handshake bounds as in tls_echo, whose shared half moved unchanged to examples/tls_echo/front.cho; authority bounded and pinned, the same labels as tls_echo. Checked: 19 byte-fed tests with no socket (every framing, one byte at a time, half-close, back-pressure, idle on the caller’s clock, 24,000 random operations), 79 of 90 mutants killed and 11 argued equivalent (the first run killed 74 and found seven missing tests), conformance/https_hello.rs, and scripts/https_hello_test.py against curl, openssl s_client and Python (14 of 14 on linux-x86_64 in CI and linux-aarch64: 2,000 requests on one connection, 300 pipelined, 200 connections, 64 MiB byte for byte, a stalled reader, 1,500 hostile requests). Measured: on CI’s x86-64, 23.4 µs of server CPU a request over TLS against 5.6 plain on one core (4.2 times; 42,000 requests a second against 178,000), 83 MB/s streamed; on Docker’s aarch64 VM 9.8 against 2.7 (3.6 times). Not profiled. Corrected: open_bytes is (heap, size, out, idle, max) and answers Opening (a kernel poller cannot be absent), the clock is an argument, room/end_input/stream were missing from the proposal, and it was 330 lines, not 100. Found: the socket path’s ready queue could in principle overflow (serve_events did not compact it); scripts/publish_packages.py did not cover packages/http-server |
| #364 | narrow into several, and examples/tls_echo_fixed | narrow(fs, "a", "b", ...) consumes an Fs or Exec and answers a tuple of capabilities, one narrowed to each literal: each literal checked as the single form is, no two equal or nested, refused on Ffi, Net and Signals with the set form named; no edition, no new tag, no backend change (the checker answers an Expr::Tuple of zero-sized parts). The report format is unchanged and names exactly the paths narrowed to and read. Checked: conformance/narrow_many.rs (both backends: two and three paths, order, confinement both ways round, /tmp vs /tmpevil, the TLS setup with open_dir, Exec), 4 accept and 17 reject fixtures, 6 of 6 mutants killed. Payoff: examples/tls_echo_fixed narrows to /dev/urandom and a fixed /etc/cancho/tls_echo; its pinned report has no fs_read("") (cost: the directory is fixed at build time, --dir refused). Found: a capability inside a tuple or struct parameter does not discharge its label (unchanged) |
| #368 | packages/http-client: a non-blocking HTTP/1.1 client, and examples/http_fetch_nb | docs/http-server.md §11.8 item 4, built. A table of N connections fed bytes and returning events (Connect, Head, Body, Done, Failed, Close), the clock an argument, no callbacks, and nothing reached but the heap (authority pinned). A request body streamed with back-pressure and Expect: 100-continue; status and headers as soon as complete; 1xx skipped; the body by length, chunked (decoded in place) or until close; a response before the request is sent read, its connection not reused; every refusal tagged (the gateway’s response.*). A pool with the strict reuse rules of cancho-hooks §53, the newest idle connection first, eviction for another upstream, and one retry as a per-request flag, replayed from the output buffer. Timeouts on the caller’s clock (connect with the handshake, head, between body pieces, the whole exchange, Expect). TLS in the example’s driver (fetch_io.cho) with the caller’s trust store and the name verified; DNS the caller’s. 118 unit tests on both backends, 517 mutants (461 killed, 56 argued equivalent), a million damaged responses with no trap, 17 live cases over plain TCP and TLS 1.3 against a raw-socket server, examples/https_hello, nginx and a hostile server. Cost: 2.9 µs of client CPU a plain keep-alive request, 7.5 µs over TLS, 3.2 ms for a new TLS connection (aarch64 VM). Corrected in place: a harness whose byte assertions could not fail, a pool default that made a benchmark churn, and a driver that spun on a blocked socket and lost an answer when a write failed first. |
| #369 | http.server: streaming request bodies and Expect: 100-continue | docs/http-server.md §12, designed first (committed alone, with two measured numbers) and built. A server that calls limits gets a request whose body is still arriving (or does not fit size) from next as soon as its head is complete, and the held request’s ticket pulls the body: body_part / body_take / body_state / body_total, and proceed for 100 Continue, which the server never sends by itself, so a gateway can refuse by route or size first (413, no 100). Input is never taken past the end of a length body; chunked bodies are decoded in place by a byte-at-a-time machine (extensions ignored, trailers checked and discarded); every refusal has a status and a rule tag (X-Rule); a deadline for the head and a stall timer for the body, on the caller’s clock; the same room back-pressure as answers, in both directions, over the socket path and the byte-fed path with one implementation. Without limits nothing changes. examples/https_hello has POST /upload (any size through the 16 KiB buffer, answered with its byte count and SHA-256), POST /upload/refuse, --read-timeout, --max-body. Checked: 22 tests without a socket and 6 with, a 50-wire chunk corpus, 8 seeds of random streams, 232 mutants (215 killed, 17 argued equivalent) and 9 socket-only ones killed, scripts/https_hello_test.py (uploads of 0 bytes to 1 GiB hashed, length and chunked; curl -T with Expect, a refused one uploading 0 bytes; stalled clients answered 408; 1,500 mangled uploads), 18 of 18 on linux-aarch64. Measured: 2,338 MiB hashed at 142 MiB/s with the server’s RSS within 116 KB; the socket path without limits and https_hello with it cost the same per request as before. Found: enqueue could write past the ready queue when a held connection was answered between inputs (§11’s shared code); https_hello resets a different connection when a client keeps sending after its own was ended (on main, filed); the file is 1,990 of 2,000 lines in cancho fmt’s form, so the next addition splits the package |
| #391 | TLS performance: the profile and the design | #378, first deliverable. A full server and client handshake profiled on an i7-1260P and an M4 VM beside OpenSSL (the server 15 times OpenSSL’s cycles, the client 11). Three operations are 77 to 91% of a handshake and each costs inside it what it costs alone; the engine’s copies, zeroing, hashing, HKDF and loop are about 5%. The surprise: every region is a 64 KiB malloc and free, and glibc trims and regrows the heap each time: 6 to 10% of a handshake and 45 to 53% of a request on a kept connection (told not to trim, https_hello was 1.9 to 2.5 times cheaper per request on x86-64 and equal to an OpenSSL server). Ranked levers with gains computed from the measured parts: the 5-times end of the target is reached without a new builtin, by 1% (arithmetic) (the allocator, a fixed-base table for the signature and one per identity for the server’s own check, X25519 on 25.5-bit limbs, NIST-prime reduction); wide multiply is the one thing the language cannot do. Targets and gates stated first; scripts/tls_perf.py check counts instructions under valgrind against benches/tls_baseline.json and runs in CI. Nothing in the stack changed; the clock of the shared x86-64 box moved by 2, so the figures are cycles and instructions |
| #394 | packages/tls: session tickets for the TLS 1.3 server | #379, step 5 of the TLS server (tls-server.md §8), part of #378. Stateless tickets sealed with ChaCha20-Poly1305 under a per-ticket subkey (a zero nonce, so none repeats across the processes of a fleet), psk_dhe_ke only, never 0-RTT. A ring of four ticket keys, made by the engine and replaced every lifetime or supplied by the program (set_ticket_keys, declarative, the same file for every process). Bound to the name, to the SHA-256 of the issuing identity’s leaf (a renewed certificate refuses the old tickets, a reload of the same one keeps them), to the suite’s hash and the ALPN protocol; lifetime capped at the certificate’s end; the age within 30 s; the binder in constant time; the engine says why a ticket was not taken (ticket_verdict). Not single use, argued and bounded (§12.4). 190 lying-client connections, 146 of 146 mutants, the interop matrix (OpenSSL, curl, Go, wolfSSL, GnuTLS, rustls, the package’s own client; mosquitto does not resume), a differential against openssl s_server, fuzz seeds. A resumed handshake costs 0.48 of a full one on the M4 (1.52 ms against 3.15) and a third to a half on x86-64. Corrected in place: a harness that waited for established while tickets were queued, and a lying client whose own binder was wrong. |
| #398 | packages/tls: client certificates (mutual TLS) in the TLS 1.3 server | #384, step 4 of the TLS server (tls-server.md §8), built on #394 (merged). tls.client_auth_mode (off, optional, required), tls.trust_clients (a store of its own, never the system bundle: at most 32 roots and 32 KiB, strict, replaced at run time with a generation count), the CertificateRequest, the client’s chain verified for clientAuth with x509_verify.verify_chain and its CertificateVerify (P-256, P-384, RSA-PSS, Ed25519), and the verified identity given to the program per slot (peer_verified, peer_certificate, peer_subject, peer_serial, peer_fingerprint, peer_san): the engine authenticates, the program authorizes. No slot memory. On the tickets: none is sent to a client that presented a certificate, none is accepted when certificates are required. Interop with OpenSSL, curl, Go, wolfSSL and mosquitto (75 rows), a lying client of 78 connections, a differential against openssl s_server, a fuzz harness, 202 mutants; examples/tls_echo, https_hello and tls_echo_fixed take --client-ca and --require-client-cert. A client certificate costs the server 1.7 ms (P-256 under the CA) to 4.2 ms (Ed25519) on an M4 |
| #237 | Signals: Signals/SignalWatch, edition 6 | Part (b) of #237 (PR number to be added when it is opened). A program that only needs to know it was asked to stop no longer needs Ffi("libc"): a capability narrowed to the signals it claims (signals("INT,TERM") in the row and in cancho authority, which stays bounded), a bitmask signals_pending, a handle the Poller waits on (a stop wakes the loop in 0.1-0.5 ms, not at the next 50 ms poll), and signals_close for “a second signal kills at once”. signalfd on Linux, kqueue EVFILT_SIGNAL on macOS (written, not run). Eight claimable signals; every other is refused under the new rule signal-not-claimable. Split gets an eighth field as a fourth declaration (edition 6), not a field on edition 5’s, which 48 files destructure. 20 conformance tests on both backends send real signals; 37 mutants killed. A local hiding a qualified std function found (min and math.min), not fixed |
| #260 | Directory listing: the names beneath a Dir | #222 slice 1 (gap L2). Decided on the design’s PR: listing goes on a Dir handle, not a path (fs_list). Edition 6 dir_list (fdopendir on openat(dir, "."), a position of its own), dir_next (one name into the caller’s buffer, kind from d_type, never ./.., ENAMETOOLONG rather than a cut name), dir_list_close; label dir_read; std.dirs.list sorted bytewise with a cap. A hostile directory of 100,010 entries lists equal to Rust’s read_dir on both backends; readdir 250 ns an entry, flat memory. 19 of 19 mutants killed. Found: the design’s dup shared the position between two listings. Slices 2 (dir_stat) and 3 (list tool) not done |
| #263 | Directory listing slice 2: a file’s status beneath a Dir | #222 slice 2 (gap L3). Edition 6 dir_stat (fstatat with AT_SYMLINK_NOFOLLOW on one checked component) answering DirStat::Ok(kind, size, mtime); a link is reported, never followed, and a FIFO answered without opening it; struct stat read at a per-target offset table. Equal to lstat on every hostile entry on both backends; 12 of 12 mutants killed. Found: Status, the design’s name, is too common to take into the prelude; it is DirStat. Slice 3 (the list tool) not done |
| #268 | Processes: the design | #237 (a), cancho-tools#10. Starting a program was the one authority cancho reached only through Ffi("libc"). Edition 7 Exec("prefix") bounds which programs start, with Fs’s path rule; the child gets exactly what is passed (no inherited descriptor, environment, console or blocked signal; LD_*/DYLD_* trap); Child is linear, so an unreaped child is a compile error and a kill cannot reach a reused pid; the child’s pipe end is consumed by exec_spawn; socket pairs, so a dead child is EPIPE, not SIGPIPE; posix_spawn, not fork. Found: every descriptor a builtin opened crossed an exec |
| #271 | Processes slice 0: close-on-exec everywhere | Every descriptor a builtin opens is close-on-exec, set by the call that makes it on Linux (openat with O_CLOEXEC replaces creat and open; SOCK_CLOEXEC, accept4, F_DUPFD_CLOEXEC) and by fcntl straight after on Darwin. A child that listed twelve of its parent’s descriptors now lists 0 1 2 and its own, on both backends. 12 of 14 mutants killed; the two survivors are equivalent on Linux, since glibc’s fdopendir sets the flag itself (measured) |
| #274 | Processes slice 1: Exec, children and channels | Edition 7 Exec("prefix") (in Split, narrowed by Fs’s path rule, labelled exec("prefix") in the authority report), exec_spawn by posix_spawn with exactly the arguments, environment and three streams passed (LD_/DYLD_ entries trap; closefrom on Linux, CLOEXEC_DEFAULT on Darwin; signals reset to their defaults), child_wait (Code/Signaled/Failed), child_kill by a std.signals bit, and channels as socket pairs (EPIPE, never SIGPIPE). 8 reject fixtures, 8 conformance tests on both backends, 22 mutants: 20 killed, 2 equivalent. Carries #273’s bound-check fix, found by the first Exec program built with LLVM. |
| #275 | Processes slice 2: watching a child and a channel | Edition 7 poller_add_pipe (a channel as a Conn) and poller_add_child: a pidfd on Linux, opened by exec_spawn and carried in the Child’s high half so child_wait can give it back; EVFILT_PROC/NOTE_EXIT on Darwin. A refused pidfd_open is the errno, carried and answered. Six conformance tests on both backends (output and exit, an already-ended child, a deadline then a kill, no descriptor leak, the refusal under seccomp, EMFILE), two reject fixtures, emission tests for all four triples. Found on macOS: kqueue will not watch a child that has already exited (ESRCH, 200 of 200), so that exit is reported as an EVFILT_USER event added and triggered in one change (two changes lost the token); and EMFILE is reachable, by a spawn whose three streams are Null, so §4.8 was corrected and a sixth test reaches it. Mutants: 13 of 13 killed (scripts/process_mutants.py; six by a hang). |
| #276 | Processes slice 3: std.process | std/process.cho: Argv (add refuses an entry containing \0, an outcome), channels, and capture(heap, clock, child, to_child, from_child, input, most, timeout) -> (Buffer, Ran). Corrected: §7’s run(heap, exec, ...) cannot be built, since no parameter takes an Exec of any prefix, so the caller spawns under its own; and Args/arg are taken. Measured on both kernels: a channel holds 8,192 bytes (macOS) and 180,224 (Linux), and write-all-then-read deadlocks at 32 KiB and 512 KiB, so input and output share a poller; draining to Again after the exit kept every byte in 600 of 600 runs per kernel, where waiting for the end waits 2 s for a background sleep. Nine conformance tests on both backends. Mutants: 7 of 11 on the first run, 11 of 11 after four sharper tests (a lingering child past the bound, the unwatchable path, two spins caught by wait4’s processor time). Found: a qualified call resolves against local bindings, reported separately. |
| cancho-tools#17 | Processes slice 4: the MCP server | In cancho-tools, on std.process: a cancho program serving the eight tools over stdio MCP, holding Exec narrowed to their directory (baked in at build, since narrow takes a literal) and no Fs, Net or Ffi. Each call is a fixed argv: the server’s --root first, flags as --name=value, operands after --. 14 conformance tests, among them each tool byte for byte equal to its CLI, --root not overridable, the limits, the protocol, a client that waits for each answer (catches a missing flush) and 300 hostile requests. 9 of 9 mutants killed after two sharper tests |
| #292 | Processes slice 5: the working directory and standard error | The two prerequisites of a run tool in cancho-tools. exec_spawn_in(&Exec, &Dir, ...): the child starts in a lent Dir (posix_spawn_file_actions_addfchdir_np, added before the closefrom, since a chdir after it is EBADF on Linux, measured), one IR node and both backends. std.process.capture_both: standard error on the same poller as the output, each channel with its own bound, one deadline, both buffers back on every path (reading the output to its end first deadlocks from 16 KiB of errors on macOS and 256 KiB on Linux; one poll set does 1 MiB each). Corrected: §4.1’s relative program path traps under a narrowed Exec. 15 new mutants killed, the eleven capture ones re-run 17 of 17; the drain after the exit has no mutant, since it is redundant by construction. Stdio::Merge would be an edition and is not built |
| (PR to be added) | Foreign authority: symbols, and a scope that is a set | cancho authority said UNBOUNDED and nothing more for any foreign call, one statx and 25 OpenSSL functions alike. It now says unbounded_by: every reachable foreign symbol as scope:symbol, sorted, one a line, so a CI pin diffs an added symbol as one added line; bounded keeps its meaning. Ffi("libc,libssl") is a set of libraries like Signals("INT,TERM") (canonical, covers is containment not a text prefix, so Ffi("libc") no longer narrows to Ffi("libcrypto")), each extern fn names one library, and a function is lent the part it needs: two libraries in one program, which could not be written. Found, and fixed: an extern fn with no Ffi was accepted – system ran a shell on both backends while the report said bounded: true and never touches foreign code; now foreign-declaration. New rule foreign-scope. Symbol-level rows and capabilities weighed and declined (37 symbols in one TLS chain; no splitting narrow). Found, pinned, not fixed: a slice is a pointer plus a length so statx(AT_FDCWD, path, flags, mask, buf) cannot be declared as C has it (strace: EINVAL), and a string has no \x |
| #278 | Threads on aarch64 Linux: the counter is aligned | Every Cranelift binary that spawned died with SIGBUS on aarch64 Linux: DataDescription defaults to alignment 1, glibc’s one-byte completed.0 put the thread counter on an odd address, and aarch64’s exclusive load refuses it. macOS was aligned by luck and LLVM by type. Word data now asks for 8 and byte data stays at 1; checked in both object formats. 14 failing tests pass on the same container. Found: ulimit -c 0 lowers the hard limit, so the differential harness could not set RLIMIT_CORE to 1; a hard zero is now left alone |
| #351 | Directory handles slice 4: a rename that never replaces a name | Edition 7 dir_rename_new(dir, from, to) -> [dir_write] Done: renameat2(RENAME_NOREPLACE) on Linux, renameatx_np(RENAME_EXCL) on macOS, so a taken destination is EEXIST with its bytes untouched and there is no look-then-rename window. A filesystem without the flag (NFS v3/v4.2 and ntfs-3g measured on Linux, ExFAT on macOS) is EOPNOTSUPP/ENOTSUP, never a replace; Linux’s EINVAL is mapped because a bad name is EINVAL too. Conformance table and a race on both backends; 10 of 10 mutants killed. The unsupported-filesystem test needs CANCHO_RENAME_UNSUPPORTED_DIR and is vacuous in CI. cancho-tools’ move not changed |
| #363 | std.json.parse_with and read_bytes | Two asks from cancho-table’s reader design (its PR 25). json.parse_with(src, tape, st): parse with the two ints of state passed in, no region per call; per line on 1M JSON lines, macOS 567 → 94 ns (and parse is 2.9x slower at 16 threads than at 1, parse_with 9.6x faster), Linux 237 → 233 (re-measured after the rebase onto edition 9, load 10 to 20 on the Mac); identical to parse, answers and tapes, on 58,733 documents and the 318 files of JSONTestSuite (json.md §3.1). Edition 9 read_bytes(io, into) -> [io_read] Read: fread on the stdin stream getchar reads, file_read’s Got/End/Failed; 75 → 5,465 MB/s on macOS, 438 → 4,290 on Linux, with the row still io_read where /dev/stdin through Fs would say fs_read; Cranelift, LLVM, wasm32-wasip1. No identity moved (1,805 std ids identical on the rebased branch; the only additions are parse_with’s body and sig). Not done: a stdin kind query, because seeking needs a File for descriptor 0 and an lseek that are not there (standard-input.md §7.5) |
| #312 | Atomics and a bounded channel, designed | Design only. Hard constraint or nobody asked? Nobody asked: the compiler already emits an atomic in every threaded binary (disassembled: lock xadd, ldaxr/stlxr), both backends have all five operations sequentially consistent, and &Atomic crosses to a thread under today’s payload rule. The real constraints are elsewhere: a borrow copies (a shared one writes nothing back), so an atomic cannot be the value and is a handle to heap words; a struct names no region, so a worker gets one pointer. And a finding the question did not ask for: the checker does not stop two threads writing one value – &! copies, two copies spawned lose updates on both backends (benches/atomics/race.cho) – so threads.md §3 is corrected in place. SC only (x86 SC store 6.08 ns against 0.34, nothing elsewhere), row conc so the folder never treats a load as pure, edition 8; a channel as a library over int messages. A gate fixed before any code: litmus tests whose plain versions are shown to fail, stress, nine mutants, a model check. Not built |
| #344 | packages/x509: a chain verified without a host name | For cancho-pg’s verify-ca and the TLS server’s client certificates. verify_chain checks the path for a purpose (serverAuth, clientAuth) and no name; verify_name is the name as its own step; verify is the two and answers as before (every recorded vector byte for byte). verify-ca follows libpq: OpenSSL’s default ssl_server purpose, no name. A refused chain’s leaf view is zeroed. A 56-case no-name matrix against openssl verify -purpose sslclient/sslserver; 33 of 33 mutants killed. Found: the design’s “a root’s EKU is not read” was wrong for client certificates (OpenSSL refuses such a root; it is read for clientAuth now), and for a server’s purpose it is a disagreement verify already had, listed for a person |
| #356 | UDP: datagram sockets, the connected half | #355, asker cancho-dns#18. The design first (a Net bound is a literal, so there is no sendto with a free destination: a connected socket for one upstream with net_out(bound), a bound one for a server with net_in, replies only through a runtime-issued peer ticket), then slice 2: Udp, udp_connect/udp_send/udp_recv/udp_local_port/udp_nonblocking/udp_close and poller_add_udp on both backends, reusing tcp_connect’s node and conn_write’s emitter. Checked: nine conformance tests on both backends (empty datagram as Got(0), oversize as Truncated(100), a stranger’s datagram never delivered, Again, poller readiness, the bound enforced, the authority row), five reject fixtures, 9 mutants killed. Found: two mutants survived the first tests (a port read from the wrong bytes; a hang instead of a failure) and the tests were strengthened; four count-pinning tables moved (selfhost tables, WASI refused set 48 to 55, label count, prelude counts) and were corrected in place. Not done: udp_bind, peer tickets, udp_send_to; the round-trip measurement against C; Darwin |
| #358 | UDP: the bound half | #355, 2 of 2 after #356. udp_bind (tcp_listen’s node without listen, net_in(bound)), udp_recv_from and udp_send_to on both backends. A bound socket has no free destination: the sender is written into a 65,536-entry runtime ring and the caller gets a ticket; udp_send_to sends only to a ticket that is positive, current, and issued to this very socket, else Failed(EBADF). Checked: a server answering its sender; two senders answered in the opposite order; five forged tickets refused with nothing sent; another socket’s ticket refused; a ticket older than the ring refused (the program feeds itself 65,536 datagrams); the authority row has net_in and no net_out; mutants killed on each backend. Found: the first design’s udp_peer was unnecessary (an out-parameter needs no per-socket state), corrected in udp.md §4; the selfhost tables and the WASI refused count (55 to 58) moved with the new builtins. Not done: the round-trip measurement against C; a std table of Udp handles |
| #370 | UDP: tickets for datagram sockets | #355 slice 4, asker cancho-dns D3. A forwarder holds a connected socket per query in flight and a Udp is a resource std.vec cannot hold, found by reading its design against the language. udp_detach / udp_attach are conn_detach / conn_attach with a kind bit (bit 31 of the descriptor half) on the one epoch table, so a Conn’s ticket does not redeem as a Udp or the reverse; std.udps works over a std.conns Table, whose slot bookkeeping is now public so it is shared, not copied (the duplicate-body test caught the first version). Eight forged redemptions refused, a three-socket table, six mutants of the kind bit killed on both backends. Not measured: the per-datagram cost of the table |
| #399 | UDP: who a ticket names | #355 slice 5, asker cancho-dns (access list, rate limiting). A bound socket answers through a ticket, deliberately not an address, so a program could not read who wrote to it. udp_peer(&Udp, ticket, &![byte]) writes the sender in the 19 bytes conn_peer (#367) uses, after the three checks udp_send_to makes (EBADF otherwise, EINVAL for a short buffer), so std.addr reads it. Data, not reach: there is still no send to an address. My first version of this, conn_peer and conns.peer with another encoding, collided with #367, which had landed meanwhile; it was dropped, not merged. Tested over real sockets on both backends; eight mutants killed |
| #395 | TLS memory per connection | #383 of #378. Measured first, at 50 to 10,000 connections on arm64 and x86-64 beside OpenSSL’s server: an established idle tls_echo connection was 119 KiB resident in a 263 KiB slot (OpenSSL 14 to 49), 80 KiB of it the integers std.ecdh’s work area and the transcripts, which every handshake writes; the 64 KiB buffer was never the cost, and nothing was ever released. Built: a core per slot (4.5 KiB server, 8.3 KiB client) and a pool of work areas leased while a handshake runs or bytes are in flight; the engine’s API and what it accepts and refuses are unchanged, open_with_areas takes fewer areas than slots (tls-pool). 17 KiB idle, 53 after a record; 12.8 KiB for a client and a server slot together at 100,000 connections; a released area is cleared (tls.residue), a connection that finds no area waits, and --areas mode ends a connection holding one past --handshake-timeout; about 1.1 microseconds (16%) more server CPU a request. 25 work-area mutants, tls_pool.cho, --areas |
| #367 | The peer of a connection | conn_peer(&Conn, &![byte]) (getpeername, 19 bytes, no row, edition 5, both backends, refused on WASI), std.addr (a pure Peer, RFC 5952 text and a parser, IPv4-mapped IPv6 normalised to IPv4, a /32 and /64 key) and conns.peer; asked at call time, not stored at accept (237 ns against 21-25 us a loopback connection; a reset connection has none, ENOTCONN on Linux and EINVAL on macOS). examples/tls_echo logs peer= on every connection line and has --per-address and --per-address-rate. Checked: eleven clients with their own ports, slot reuse, FIN and RST, the buffer limits, an IPv6 and a mapped peer through a dual-stack socket put under the listener, 9,000 lines against Rust’s std::net, the authority report unchanged (and pinned for tls_echo), a client from 127.0.0.2 unaffected by a bound on 127.0.0.1 (Linux). Found: every cancho socket is IPv4, so no cancho listener can be reached from ::1 until tcp_listen is dual-stack; the first draft’s duplicate hex helpers were refused by the duplication check. Not done: X-Forwarded-For in packages/http-server, the broker’s limit, a UDP peer |
| #396 | TLS client: client certificates and ALPN | #386. The two of the issue’s four gaps a gateway needs, built, and the other two decided in writing. Client certificates (mutual TLS to an upstream): tls.open_mutual, add_client_identity (four P-256 identities chosen by the host name, never sent to a host they do not name), a CertificateRequest answered with the chain and a CertificateVerify (std.ecdsa_sign.sign_checked, hedged) only if the request lists ecdsa_secp256r1_sha256 and its authorities are empty or name an issuer of our chain, otherwise the empty Certificate; TLS 1.3 and TLS 1.2; a ticket is not offered after an identity changes or the client certificate expires. ALPN: an offer, the choice reported, a choice not in the offer refused. Against OpenSSL, nginx, Go and wolfSSL (92 rows) and openssl s_client (67 flights, 0 unexplained); 77 lying-server and 10 engine cases; the signature costs about 1.8 ms of client CPU. Found: OpenSSL accepts an unoffered ALPN choice and sends its certificate to a request that does not name its issuer. Decisions, proposed: revocation: build nothing now (the public web is leaving OCSP; the stapled path is 400 to 800 lines of parser for a soft check); X25519MLKEM768: not now (no server found that requires it; about 700 lines and a constant-time audit; triggers to revisit written down) |
| #421 | std.crc | #352, two askers (cancho-cache’s cluster slots, cancho-log’s record check). CRC-16/XMODEM (Redis’s crc16 loop, table-free at 16 bits), CRC-32/IEEE, and two CRC-64s (CRC-64/XZ and CRC-64/REDIS, the “Jones” polynomial) — whose state genuinely uses the top bit, which a 64-bit signed language whose >> is arithmetic and whose all-ones mask is not a writable literal cannot hold in one word. The design is two 32-bit halves, state and table both, no mask ever needed; docs/crc.md §2 records why an unsigned-shift emulation was tried and rejected. Corrected in place (twice): the issue’s CRC-64 check value 0xE9C6D914C4B8D9CA is right, and it is Redis’s: the Jones polynomial 0xad93d23594c935a9, reflected, init and xorout 0 gives exactly it (computed independently in Python; it is the value in Redis’s own crc64 test). An earlier version of this row said it matched no combination, which was false (it had been tried against the wrong polynomial); std.crc ships both crc64_* (CRC-64/XZ, ECMA-182, check 0x995DC9BBDF1939FA) and crc64_redis_*, and the accept test gates both |
| #421 | Net bounds | #362, asker cancho-dns. Design only: the one-Net-one-bound-two-meanings defect a listen-and-dial program cannot carry, the host-prefix that admits ns1.example.com.attacker.net, and the report that cannot say “several”. Three steps: fix the report’s lie first (bounded: true on an empty bound, a one-PR change that is also the cheapest honesty fix in the file), then several-children-from-one-narrow mirroring Fs, then the direction split and exact host matching together in the next edition. Deliberately no runtime connect-to-address, no inbound host bounds, no DNS resolution in the checker |
| #421 | One store, several threads | #354, asker cancho-cache. The answer the issue asked for: yes, the design exists, but on two primitives that do not — T-P3’s cross-thread lend (a checker rule first: a new region relationship, checkable locally or not at all) and a poller wake-up from another thread (pure runtime, no language change, meaningful only with the lend). Recommendation: the epic stays process-per-core — which it already says it does not depend on — and the shard design is recorded so it is not re-derived. Sequencing argument recorded: T-P3, then the wake-up, then §2 |
| #415 | Structured ingest | #411. The missing half of cancho print: the canonical AST as JSON (print --output json) and back (cancho ingest), so an agent writes data and never a token-level spelling error. The two §2a contracts hold over JSON too, enforced by the same walker over the same corpus — every accept and reject fixture, the examples, the packages and the whole stdlib, identity-preserving and a fixed point. Three new rules (ingest-json, ingest-node, ingest-arity) because they are new ways to be wrong, not new wordings: not-JSON, an unknown vocabulary word, a known word with the wrong shape, each answered with a position into the JSON. Building it found one real bug in the encoder: float bits crossed as a signed integer, so a pattern above i64::MAX came back negative — the bits cross as a number’s text now, for the reason canonical-ast.md §3 gives. Names are checked against the lexer’s own rules, so a tree built from data cannot contain a name no .cho text could write |
| #420 | Foreign linking | #388. extern fn free collided with the LLVM backend’s own arena declaration — invalid redefinition of function 'free', answered internal, and cancho-robot’s first spike had to leak the string instead. Fixed by declaring malloc and free only when the program does not (the guard calloc, listen, accept and the rest already had); the backend calls through its own call-site type. A first version moved the arena to undefined @cancho_malloc/@cancho_free names and no --backend llvm program linked; the regression test now builds and runs a program with its own free/malloc beside a region and a boxed slice |
| #422 T1 | Tty | #422 T1, asker cancho-robot. The serial-port capability’s design: both termios ABIs from the spike’s measurements (72/8 vs 60/4 bytes, the macOS two-step and its measured IOSSIOSPEED — which pyserial hard-codes wrong, and neither belongs in a program), the variadic-ioctl trap recorded as the argument for the whole epic, the seven-verb surface, tty_read/tty_write labels answer never touches the filesystem-while-opening-a-device (finding 6), and the four open decisions answered: prefix narrowing (non-empty, for the reason net-bounds.md §2.4 records), a plain validated integer for speeds, edition 8 (shared with #400’s word-scan), V4L2 deferred. One new rule tag, tty-path. T2–T4 implementable from this; T5 is the robot’s, hardware-gated |
| #410 | vcs sync | #403 items 1 and 2. The store rides inside git: cancho vcs sync recomputes every declaration’s identity per file and writes a sorted plain-text .cancho/ids.txt (merges as text, conflicts land on the declarations that truly collided) plus op records (AddFunction, ModifyBody, RemoveFunction) in the OpLog shape vcs publish writes; --check writes nothing and exits 1 on any semantic change, for CI. A signature change is reported and counted but has no op vocabulary yet, so its index entry is overwritten in place (said so in the output). A root-module name declared differently in two files is reported as a collision and turns --check red rather than silently overwriting; symlinks are never followed. A full sync of this repository indexes 3,777 declarations |
| #424 | Tty, the capability | #422 T2. res Tty, prefix-narrowed as Fs is; the Port handle (Udp’s shape — one name cannot be two types, so the capability keeps Tty and the handle is named, as Udp is beside Net); the tenth Split field (after edition 7’s exec), edition 8; seven builtins across both backends, with the measured termios numbers in cancho_ir::tty so the two backends cannot disagree. The macOS IOSSIOSPEED step is the variadic-ioctl trap written once as a fixed call, not nine extern fn declarations ABI-hacked per program — the spike’s control run is the whole argument. The row: tty_open(prefix) argument-carrying, tty_read/tty_write path-free (the prefix spent at open, udp_recv’s shape), so a robot-shaped program reports bounded: true with no foreign line. WASI refuses the family (tty.md §9: no termios). One rule re-used (capability-not-narrowable); examples/tty_hello.cho opens-configures-pings-closes on cranelift, with the failed-open path exercised for real |
| #424 | Tty: the poller, the measurement, the report | #422 T3+T4. poller_add_tty was already the family’s sixth member from T2’s dispatch — T3’s deliverable is the honest end-state: examples/tty_wait.cho puts the port on the poller and measures: ten 50 ms waits, timed by clock_ms, printed — 502 ms with nothing registered on this machine, the spike’s own 501 ms baseline reproduced by an example rather than asserted by a log. T4’s acceptance: cancho authority answers bounded: true with tty_open("/dev") argument-carrying and no foreign line — the robot’s founding sentence, now a command output. The narrowing fixture (tty_narrowing.cho) pins the prefix story with the path inside the prefix (a path outside traps at run time by design — the check is the capability’s teeth, Fs’s own behaviour, confirmed identical); the reject fixture pins the widening refusal. One honest detour recorded: the out-of-prefix trap first read as a compiler bug and was bisected across Fs before being recognised as the security mechanism working |
| #400 | Scanning a word at a time | The gap the cancho-table CSV scanner found (gap-scan.md there): no word load, no bit counts, no vector compare mask. Edition 9 load_le64, byte_mask64, trailing_zeros, leading_zeros, popcount: pure; an out-of-range load or mask traps as indexing does, the counts are total. Both backends (LLVM IR; Cranelift ctz/clz/popcnt/vhigh_bits) and wasm32-wasip1 (vectors scalarised, still 69 refused). On 1 GiB the mask counts a byte 15-21x faster than the byte loop on arm64 and x86-64, both backends (a loaded Mac, a shared gram: instruction counts are the result); against memchr for a first match it is 1.1x (LLVM) to 2.1x (Cranelift) slower. Found: x86-64 popcount is a software sequence on both backends; a pure kernel in a timing loop is hoisted or dropped by LLVM, so an in-program clock lied; the conformance tests’ shared scratch directories fail when two checkouts run them together. Differential test against Rust, 20 trap-edge calls, 21 of 21 mutants killed; no existing hash moves |
| #412 | cancho satisfy | #406. The manifesto’s spec-to-implementation loop as the smallest composition of machinery that exists: a contract is a .cho file whose test_* functions call the candidate; satisfaction is check plus test against the pair, and the verdict names each candidate declaration’s recomputed SigId. Exit codes are the composed commands’ own (0 satisfied, 1 checker refusal, 4 test failure) plus 2 for a contract with no tests of its own or one that never calls the candidate, so a vacuous contract is refused, not passed. The first committed contract/candidate pair (max) runs in CI |
| #409 | Stability plateau, held mechanically | #405. hash-stability.md §3’s number was a paragraph that had to be corrected when it climbed. scripts/history.py --explain now replays every distinct .cho revision under std/ and examples/ through today’s compiler and exits 1 on any failing rule not in the committed exemption file, on any rule whose revision count grows past its recorded count, and on a listed rule that no longer fails; the stability-plateau CI job runs it on every push. Baseline: 210 revisions, 140 failing (67%): duplicate-declaration 105, unknown-name 34, not-a-function 1. Found while reconciling the figures: the harness only replays .cho paths, so the 382 older .ls revisions are not in the number, and 67% is not comparable with #163’s 39% |
| #393 | mul_wide, add_carry, sub_borrow | Three edition-7 pure builtins over int pairs (unsigned words, nonzero carry), lowered without branch or libcall on LLVM (x86-64, aarch64, wasm32) and Cranelift; std.x25519 moved onto std.field25519_51, five 51-bit limbs. Checked: 6 M random cases against u128 on both backends, 200,000 rounds against OpenSSL, dudect 10^6 on LLVM at max abs t 1.90 (x86-64 and M4), ctgrind 0 reports with its planted control seen, 20 of 20 lowering and 36 of 39 field mutants. Measured: X25519 23x faster on arm64 (0.575 to 0.025 ms), 18x on a loaded x86-64. Found: LLVM turns a 32-bit-halves multiply back into __multi3 on wasm32; Cranelift’s X25519 fails dudect on the M4 unless DIT is set; review found the field’s stated input bound false (fixed) |
| #397 | std.p256: faster P-256 | #380 of #378. Profiled first (field operations 93-98% of every P-256 operation; a bigmod.mul 115 ns, half of it checked arithmetic). A ten-limb 28-bit field with straight-line kernels written by a generator that proves their wrapping_* cannot wrap, the prime’s form in the reduction, lazy reduction with every formula’s bounds checked by a script; k·G as 65 table entries (a static) read under masks; wNAF verification. M4: public_key 768 → 35 µs, sign 820 → 59, verify 814 → 106, shared 755 → 138. dudect at 10^6 on x86-64, the branch audit, OpenSSL and RFC 6979 differentials at 10,000, mutants (§8). Found: a function a static calls is emitted into every --std program; a lazy element has many zeros. Not done: P-384, carry-free additions |
| #392 | Multi-block AES-GCM and aggregated GHASH | #382 under #378 (TLS performance). A profile first: the AES instructions were 17% of a 16 KiB record, the rest a byte-at-a-time XOR around a call a block and GHASH reduced a block at a time, and 88% of a 64-byte record on Darwin a region’s malloc. aes_ctr32 (eight blocks interleaved, the XOR inside), GHASH in the reflected order with twisted powers of H and one reduction per eight blocks, the tag in one call with no allocation; ghash_update retired but kept, so earlier edition-7 programs still read. Measured 5.9 to 11.2 times at 16 KiB; the record layer is what dominates a TLS record now. |
| #444 | TLS performance as built | #378, after #392 to #398. The profile of #391 measured again on the same i7-1260P and M4 VM beside OpenSSL: a full server handshake 2.86 times OpenSSL’s cycles (was 15.1; arm64 2.12 times its CPU), the client’s 2.77 (was 11.4), a resumed one 2.34, a kept request 1.11 to 1.15; T1 to T4 met, T5 not. §4.3 had projected 4.9 times with two levers that were not built: the operations became 6.8 to 17 times cheaper where 1.7 to 4 times on their field multiplications was assumed. The allocator is now the largest cost of every handshake (the same 0.9M cycles, now 32% of a full one and 52% of a resumed one, 112 brk calls); glibc told not to trim made them 34% and 55% cheaper (1.87 and 1.05 times OpenSSL); a kept request no longer reaches it, and spends 24 to 27% on #395’s work-area copies. Next levers ranked by measured gain (A1 in #375, P-256 on mul_wide, the per-identity table, a SHA-256 builtin, HMAC pads, the work areas, in-place AEAD: #445 to #448 new, #380 and #381 reopened). False claims corrected in place; the guard’s linux-aarch64 entry re-recorded, 33 of 33 rows on both entries; tls_perf.py openssl, scripts/tls_handshake_cost.py and tconn ... resume added |
| #450 | A verified client’s leaf outside the work area | The memory cost #398 took on purpose, measured with #395’s harness on arm64 and x86-64 beside OpenSSL’s server verifying the same certificate: a tls_echo connection whose client presented a verified P-256 certificate was 126.0 / 127.7 KiB idle at 10,000 connections against 17.0 without (OpenSSL 31.5 / 61.0, 19.8 / 33.9 with its buffers released), because the leaf in b_leaf kept the whole work area; --areas 64 held 64 of 200 such clients, and --areas ended every one record-timeout (a bug, found measuring). The identity fields alone cannot keep peer_certificate and peer_san’s ranges, so the leaf’s DER goes in a 2,048-byte room a server slot, copied when the Finished verifies, emptied on failure and drop and counted by tls.residue; a larger leaf keeps the area. After: 19.0 KiB on both, 200 of 200 on 64 areas; the API and the recordings unchanged; 16 new server mutants, 223 of 227 killed, 4 equivalent |
| #457 | Region chunks kept per thread | A1 of #378 and #375, one mechanism. Every region was a 64 KiB malloc and free, and glibc trimmed and regrew its heap for nearly each: 112 brk calls a full TLS server handshake, 136 a resumed one. Each thread now keeps the chunks its regions give back, at most 16, last in first out (the region stack), and the bytes a region used are cleared when it ends; a pthread key’s destructor frees a thread’s chunks; both backends, a global on WASI. Measured against its base, alternating: 9 brk calls in a whole run; the server’s full handshake 2.821M to 1.844M cycles on x86-64 (1.86 times s_server; arm64 1.50 times its CPU), a resumed one 2.001M to 0.884M (1.04 times OpenSSL’s resumed: T5 met for the server, not for the client at 1.21); clearing +0.1 to +0.3% of the instructions; macOS, 16 threads of 300,000 regions, 8.76 s to 0.04 s; RSS at 10,000 connections unchanged a connection. 14 of 14 mutants killed |
| #463 | Work areas that rest, and a record layer that seals in place | L1 and R1 of #378 (#447, #448). The 24% of a kept request #395’s lease and release cost was the core’s 296 words copied one at a time through the engine’s boxes: block copies, and an established connection that rests in its area until another needs one (at most 32, tls.set_resting; a handshake takes a resting area first). seal_in_place/open_in_place for AES-GCM (both paths) and ChaCha20-Poly1305; the record sealed where it is sent from, opened straight into the received data, no region. A kept https_hello request 40.0k to 31.4k cycles on x86-64 (1.20 to 0.94 times OpenSSL’s oserv); a record through both engines 5.7 (64 B) to 1.5 (16 KiB) times cheaper; idle memory unchanged, at most 2.1 MiB more per engine, the high-water mark 16 KiB a connection lower. 27 new TLS mutants, 9 GCM, 8 ChaCha20 |
| #465 | A table for a key that verifies many signatures | B2 and B3 of #378, #380. A P-256 key that verifies signature after signature (a TLS server’s own, checking each it makes; a client’s trust anchor) keeps a 10,305-byte table built once: its odd multiples in four runs of 65 wNAF digits, so a verification has 64 doublings, not 256. The table goes where the point went, so no std signature changed (a first version’s _tabled twins broke the stability plateau). One per identity in the server’s configuration, one per P-256 root (at most 16) after the client’s store; never per slot. gram: a verification 688k to 326k cycles (1.29 times OpenSSL), the server’s full handshake 1.85 to 1.46 times s_server (−24% instructions), the client’s 1.62 to 1.41 times tconn; arm64 1.23 and 1.31; nothing a connection, +44 to 48 KiB a process. Every vector through both paths; 49 of 53 mutants killed, 4 argued. P-384 measured (9 to 16 times OpenSSL, 7 of 31 real-chain signatures) and decided: worth it, on #381’s field |
| #466 | SHA-256 on the CPU’s instructions, and HMAC with a key’s pads hashed once | #445 and #446 under #378 (levers D3 and D4). hw_sha256, sha256_blocks and sha256_block_words (edition 9, pure; SHA-NI and ARMv8 SHA-2 on LLVM, a trap on Cranelift and WASI), std.crypto on them with its partial block as words, and prepared HMAC keys in std.hmac/std.hkdf and the TLS key schedule. SHA-256 at 0.78 to 1.00 of OpenSSL’s cycles on x86-64 (was 4.6 to 9.8); a full server handshake 11.5% cheaper (1.65 times s_server), a resumed one 27.6% (0.76 times OpenSSL’s), arm64 alike: 90% and 88% of the projected cycles. Found: Valgrind hides SHA-NI, so the guard’s x86-64 entry counts the software path (and Valgrind is a CPU without SHA-NI for the forced-off tests); hmac.final writing its inner digest into the caller’s out made the time depend on out’s contents; the pooled dudect harness measured where a record lay. KDF differential, CAVP with SHA-NI hidden, 34/34 std mutants and 33/33 IR mutants, dudect at 10^6 all under 4.5 |
| #464 | P-256, bigmod and Poly1305 on mul_wide | #381’s remaining scope. P-256: four 64-bit limbs and five 56-bit limbs measured first; five 56-bit limbs inside the generated kernels chosen (R, the representation, #380’s files and the lazy bounds unchanged), 25 mul_wide a multiplication. std.bigmod: 64-bit limbs, CIOS with two carry chains. Poly1305: 44-bit limbs, a block loop. Measured (x86-64 cycles): P-256 ECDH 1.73x (3.21 times OpenSSL), sign 1.45x, verify 1.64x; RSA-2048 verify 6.8x; P-384 3.9 to 5.1x; Poly1305 2.3x; a full server handshake −19.9% (1.48 times s_server), a client’s −20.3%. Checked: every vector, differentials at 0, dudect 10^6 (P-256, P-384, ChaCha20-Poly1305) under 2.5, the branch audit, mutants all killed or argued; both guard entries re-recorded. Found: #397’s estimate that 4x64 stays inside the generator was wrong; a stale ecdh_mutants mutant since #397 |
| #503 | tcp_listen_on | #498: tcp_listen binds every interface, so no program could say it listens on loopback and cancho authority printed net_in("8080") for a server meant for a local proxy. One new edition-9 builtin, tcp_listen_on(net, host, port, backlog, flags), whose host is a string literal holding an IPv4 address (names, IPv6 and run-time hosts refused, no DNS); the inbound Net bound now names the host and the port, so a loopback listener’s row is net_in("127.0.0.1:PORT") and a Net can forbid the all-interfaces form. tcp_listen is unchanged (same behaviour, same row) except that on a Net whose bound names a host it is now the compile error listen-host where it used to trap on every run. Tested over real sockets on both backends: a loopback listener is reachable on 127.0.0.1 and refused on the machine’s LAN address. 15 mutants killed. macOS aarch64 only; IPv6, DNS and udp_bind with a host are not done. |
| #538 | Stopping what a detached worker started: child_kill_group | #533 (found by cancho-forge’s S7 spike), #536. child_kill_group(&Child, signal) (edition 9): child_kill’s signals to the process group the child leads, kill(-pid), in both backends and as builtin 138 of the compiler in cancho. Row child_signal, so the report shows a signal on children and a supervisor needs no /bin/kill and no exec("/bin") (tests/accept/process_group_kill.cho reports exec("/bin/sleep")). A plain child is not signalled and the parent cannot be: the group -pid is not anyone else’s, so the answer is ESRCH (measured), which is why no type split was needed. std.process.capture sends the group KILL before the pid’s, so the S7 scenario (a worker’s sleep 77, a 300 ms deadline) leaves no live grandchild; the plain-worker control leaves it S. Four mutants killed (capture, Cranelift, Rust LLVM, the runtime of the compiler in cancho). Not closed: a supervisor that crashes has no Child (§9). processes.md’s status line said slices 3 and 4 were not built; it is rewritten with a table of what is built where, checked by grep, and wasm.md’s builtin counts, stale since #524, are corrected |
The pattern, if there is one
Twenty-one of the first twenty-nine slices found a bug, falsified a claim
the project had already written down, or both — and three of those were
soundness bugs reachable from ordinary code. Re-counted over the
twenty-one slices since, on the same criterion and by reading the rows
above: seventeen did, so thirty-eight of fifty. The four that did not
are utf8.md, AGENTS.md, the sort port and the backend audit — the
last of which checked four claims and found all four true, which is the
outcome this paragraph should be able to record without the count
looking like a failure. That is not an accident of luck: each
slice is built by writing the thing the previous document said was
possible, and the documents keep being wrong in the same direction —
optimistic about what generality the type system already had.
The convention that follows is worth stating, because it is why the
documents are trustworthy at all: a falsified claim is corrected in
place, in the document that made it, rather than quietly edited.
sharing.md corrects linearity-and-effects.md §9;
collections.md corrects both standard-library.md §4 and
mode-polymorphism.md §1, the second of which was itself a correction;
and #36 corrects reading-references.md §2.0 and sharing.md §2.1,
where a refusal turned out to be broader than the hazard it was written
for; and #41 corrects a refusal that named () as a foreign result, in a
language whose grammar refuses () on purpose — two correct rules with a
loop between them, which only a program walking into it would find; and
#42 corrects README.md’s performance expectation and
defined-behaviour.md §2.1, which had put a number on the overflow trap
that nobody had ever measured — the one claim here that an outside reader
caught before the project did. And #55 corrects canonical-ast.md §8 —
the section whose whole job is to say what is not a contract — which had
described a struct literal’s field order as a gap it does not have and
given the wrong reason for the one gap it did have. A wrong entry there is
worse than most, because it is the entry everyone else reads to find out
what they may rely on.
What is next
| Next | Why it is next |
|---|---|
&! mean unique? | Answered, no — aliasing.md. This row priced the decision as “it refuses programs that compile today”, and across all 82 programs the repository builds it refuses one fixture, written to document the behaviour being changed. The price is elsewhere, in two places that each settle it alone. There are three ways to alias a &!; two close syntactically and the third, both(head(s), s), does not — nothing in head’s signature says the result borrows its argument, and a shared region is a shared arena, not a shared object, so carrying that fact across a call is lifetimes and a borrow checker, which the README’s design commitments rule out by name. And even proved, the fact has no consumer: Cranelift has no noalias, only three fixed WebAssembly alias regions. The two conditions that would reopen it — an LLVM backend, and parallel lanes to make it a correctness argument rather than a speed one — are §6 there |
Built — llvm-backend.md. layout.md §3 and overflow-cost.md §3.2 turn out to be the same wall wearing two hats: a trapping add is not reassociable, so the loop does not vectorise, so neither contiguity nor reassociation can be spent. bulk-io.md §4 puts a third hat on it — a byte-at-a-time slice copy costs 6 ms per 8 MB here and nothing measurable in C, which is now the largest single thing between base64/ and coreutils. It is the one thing that would move several of these numbers at once. And check-cost.md says where the traps are in the way: six of eight loop-body checks make their operation non-reassociable, so a backend with a vectoriser would collect the gain only outside those, and the list of places is §2’s table there. And backend-limits.md checks the case against the backend rather than from memory: there is no aliasing fact to emit, no purity fact to emit, and no pass that makes SIMD out of scalar code — each a property of Cranelift’s design rather than a version it has not reached. The three arguments for LLVM are one argument: this language computes facts its backend has no vocabulary for. How, not whether, is now settled, and its first five slices are built — hello.cho itself now builds through it. llvm-backend.md: textual IR shelled out to clang, no new build dependency, and a spike that found the obvious way to emit the overflow trap raises the wrong signal on one target (confirmed on both this project’s targets by this backend’s own tests). §5’s doorway, checked arithmetic, control flow (needing no phi, because every local here is already memory), slices/strings, and structs/enums (match reusing the same memory-backed reasoning a third time) are all cancho-codegen-llvm behind --backend llvm, checked against the Cranelift path’s own output — examples/hello.cho, ci.yml’s own smoke test, was the fourth slice’s own target, byte for byte the same through both backends. Whether clang -O2 actually vectorises a loop-heavy checked-arithmetic kernel through this backend is now measured, and the answer is no (#102, llvm-backend.md §7): objdump on the -O2 object for both sum_checked.cho and mandelbrot.cho finds zero %xmm/%ymm/%zmm instructions — this document’s and check-cost.md’s own “a trap is not reassociable” finding holds on a real backend, not only Cranelift’s absence of one. What -O2 bought instead was scalar: run_clang had never passed clang an optimisation level at all (a real bug, fixed), so mem2reg never ran and every leaf stayed a real stack slot — once fixed, register allocation and instruction selection alone are enough to put cancho/llvm within noise of C on mandelbrot.cho (0.96×–1.00×, against Cranelift’s 1.6×–1.8×) and 8%–54% ahead of Cranelift on the three kernels that build on both backends today. #103 closed wrapping_add/sub/mul and found that removing an observable trap lets LLVM delete a whole no-op loop outright, the same fact from the other side. #104 is where the vectorised half of this row’s own claim is finally measured, not just theorised: region/alloc_slice closed, sieve/scan build on both backends, and their wrapping halves actually vectorise (409 SIMD instructions in scan_wrapping.cho, 22 in sieve_wrapping.cho) where their checked twins compile to zero, objdump-confirmed. So the claim holds in both directions now, not only the negative one: a checked loop does not vectorise, and the same loop with the trap removed does. #105 closed heap boxing and, underneath it, multi-leaf function returns (reduce_checked.cho — docs/gpu.md’s own kernel — confirms the same vectorisation split a fourth time). #106 closed getchar/s[a..b] and found revcomp.cho’s own boundary is Place::Field/Place::Deref, not getchar as assumed. #107 through #119 closed everything that was left: field/deref access and revcomp.cho itself (#108); arg_count/arg and fannkuch.cho (#109); single-value alloc/box/unbox and binarytrees.cho (#110); Type::Float, the last gap with a benches/ target (#111); matching through a reference, closing every gap this document had a target for (#112); then Net itself, scoped directly rather than by a target — listen/accept (#113), bind (#114), connect (#116), fully built on both backends, checked against a real accepted connection and an all-LLVM listener-and-client pair talking over real loopback; Ffi/extern fn (#117), which found the “only gap left” claim false three PRs running before it — Fs had been unbuilt and unnamed the whole time; Fs itself (#118), closed alongside a byte-comparison bug hardcoded since this backend’s first slice; and Expr::Static/Expr::BitNot (#119), the last two Expr variants, closed alongside the discovery that the backend’s own expr match was not actually exhaustive despite four separate “only gap left” claims saying so. With #119, body/expr.rs’s match has no unhandled Expr variant and no wildcard arm — rustc itself now refuses to build the crate if a future variant goes unmatched, and every fixture in tests/accept/ and examples/ builds on --backend llvm. The one thing still refused, a user’s own extern fn socket colliding with this backend’s internal declaration, is #92’s already-accepted exposure repeating itself, not a missing feature | |
exp, log, pow | Built — float-math.md §7. Three programs asked by name (examples/growth.cho, decay.cho, entropy.cho), clearing the two-asker bar this row itself said was unmet. Library code in std.math, checked against Rust’s own f64::exp/f64::ln/f64::powf within 1e-9 relative error — not correctly rounded, the same honest limit sqrt itself could not clear as a hand-rolled function. sin stays open: nothing has asked, and it needs its own range reduction |
Built — file-handles.md. open_read, file_read, file_close, a res File, and a three-constructor Read so the end of a file and a failure stop being two meanings of -1. Building it settled the three things §6 left open and found one the design had not seen: Result[File, int] cannot be a builtin’s signature, because Result is std and std is opt-in rather than a prelude. Failed(int) now carries the real errno, which is the one argument for handles that survived §1’s other two evaporating. The price nothing had counted: three prelude names cost 31 fixtures and the extern fn read in examples/serve/, so the two verbs are file_read and file_close rather than read and close | |
Built — benchmarks-game.md §7. The Game’s spec was reachable this session, and the measurement corrects the prediction rather than confirming it: fasta is not nearer base64’s 1.59×, it is faster than C, at 0.54× | |
lex-vcs | Answered, yes, and built — vcs.md §7. This row’s own plateau question — whether 27 commits (#93–#119) touching zero builtin/effect/prelude files was enough, given hash-stability.md’s own disagreeing pair of numbers (the encoder moved 20 times unobserved; the language moved enough that 71% of this repository’s .cho history no longer type-checks) — is answered yes, conditional on tagging every op’s edition from day one so the thing a plateau insures against costs a reinterpretation rather than a rewrite. crates/cancho-vcs exists (#123–#125): a native crate, no dependency on lex-lang, sharing lex-vcs’s scheme and no code — the same “shares the idea and no code, no cross-repo Cargo.toml edge” relationship lex-os/cancho already committed to, with one detail changed on contact (OpId is BLAKE3, not lex-vcs’s SHA-256, for the same reason cancho-id already picked it). Built: the Operation/OpId/SigId/StageId/EffectSet foundation checked against real cancho-id hashes, the apply→gate pipeline (gate.rs, checking §3’s claim directly rather than only arguing it), a loose-file op log, and a hash-chained attestation log sealed with Ed25519 (ed25519-dalek, the same crate lex-os-audit uses). What §3 called “largely unmodified” and is still unbuilt — whole-function merge, multi-file merge sessions, typed issues, predicate branches, the op log’s own history index — has no asker yet; vcs.md §8 tracks it |
Answered, no — line-reading.md. This row said examples/cut/ and examples/tally.cho “both read getchar into a fixed buffer”, and tally.cho has no buffer: it streams. Five programs call getchar, one of them reads a line at a time, and the bar here is two. What the row was right about is that something was wrong: cut was silently printing the wrong field on a long line — a truncated line loses its delimiters, so it switched to the no-delimiter rule and answered 60 KB of a with exit 0. Fixed by growing on the heap, at 1.18×, and the one function that was earned is buffer.clear, at 13.0× against the alternative | |
Answered, no — effect-polymorphism.md. This row and mode-polymorphism.md §8 were the entire design, and counting by reading says the feature has nothing to quantify over: 537 functions here write a row and not one could be polymorphic in it. A row is fixed at the declaration (Signature.effects, read straight off it by every caller), and the two things a function is generic over — types and regions — cannot select a callee. The nearest asker, io.write_all against io.error_all, differs by the builtin as well as the row, so collapsing it needs a function value: row polymorphism is the shadow of higher-order code, and Rule::NoFunctionValues refuses that with a fixture |
Superseded by the row above, kept as it was written. Everything from
here to “not ours to do” argues for building an LLVM backend, written
before it existed. It exists now — the row above this one, and #127
(docs/llvm-backend.md) made it the default. The 1.17–2.58× this essay
names below is the number that argument was made against, not a current
figure: docs/benchmarks-game.md §8 re-ran the same five kernels on
--backend llvm and found a narrower, less uniform range, one kernel
(fasta) still faster than C. The reasoning stays, for the record of why
this was prioritized; the number it ends on does not.
An LLVM backend is not next, and the case for it is now argued from two directions at once — which is worth keeping written down, because the two arrive at opposite conclusions about its priority and agree about its necessity.
purity.md §4.2 shrank it: the effect row’s advantage needs a
compilation boundary, cancho compiles whole programs, and LLVM infers
the same fact itself when it can see every body. So the row buys less
than it looked like it would.
gpu.md §2.3 grew it, from somewhere else entirely. Asked
what a GPU would cost the design, the measurement found that deleting
the overflow trap does not reach C: cancho without traps is still
scalar, 2.27× off vectorised clang and 1.55× off scalar clang, so the
trap is 1.83× of its own cost and everything remaining is Cranelift.
Removing the trap is necessary and not sufficient. That makes LLVM the
prerequisite for two things rather than one — the CPU gap, and any
GPU future at all, whichever language the kernels would be written in.
It remains the answer to the 1.17–2.58×, and that is a performance problem in a language that is not yet usable. But it is no longer only a performance problem: it is the gate in front of the one direction this project has been asked about and cannot currently go.
jo instead of seto/test/jne (overflow-cost.md §3.4) was listed
here as ordinary work blocked by nothing, and
emitted-checks.md §2 says otherwise: that sequence
is Cranelift’s x64 lowering of trapnz, so it is an upstream change
rather than one this repository can make. It stays worth doing and is
not ours to do.
Flag parsing has moved off this list and into
flags.md — and it was not the small thing this line called
it.
Standard error is done — standard-error.md.
This list called it “a gap reach.md §6 upgrades from an omission to a
named gap”, and reach.md §6 never mentioned it; the citation was
copied from file-handles.md, which had invented it. Closed as a third
label on Io, after measuring that every failing program here produced
zero bytes on every stream and that the workaround both corrupts a
pipeline and loses the message on a trap.
Environment variables moved off that list and onto reach.md’s: getenv
returns a char *, so they are not reachable through FFI at all and want
a capability with builtins, the way Args has.
Deliberately excluded
Borrow checker, traits, comptime, an own optimiser, incremental
compilation, LSP, async.
Each is “yes, later” rather than “no”. Saying yes early is what turns three months into three years, and the non-goals say which of them are “no” outright.
Beyond
Inside cancho, the next phase is next-phase.md:
not a feature list — “What is next” above has nothing left unstruck —
but a proposal to replace hunting for stale docs and duplicated code
with a mechanical check for the latter, argued from MANIFESTO.md
and from three of this session’s own mistakes. Its §3 found a real
instance (ten examples duplicating std.io’s print_nat/write_all,
most of them still on the pre-bulk-I/O putchar loop docs/bulk-io.md
replaced) and migrated seven of them; §3.1 records why the other
three stayed (modular/text.cho’s own worked example for
docs/modules.md, hello.cho’s load-bearing role in three unrelated
tests, buffer/main.cho’s copy was dead code and just deleted) and a
root-namespace import collision the migration found along the way.
§4’s own standing check — the actual proposal — is built now
(duplication::no_function_body_is_duplicated_across_files), and
being alpha-equivalence-aware rather than text-based, it immediately
found three more real duplicates the original hunt’s regex could not
have (§4.1): two under a different name on each side (larger/max,
append/put), and a package (net.connect) whose own extraction
never reached one of its three source files.
Beyond cancho, the first real target is lex-os — production
systems work, no rewrite risk.
Self-hosting the lex-lang toolchain was a spike before a plan, and
the spike has run: self-hosting.md. Not against
the “~136k-op corpus” this line used to name — no such corpus is
checked into any repository this project can reach — against the real
.lex corpus that does exist. The canonicalization algorithm ports
byte-for-byte (168/168 real Stages), and nothing structural blocks
it (recursion, heap-allocated recursive data, and a named way around
the Cranelift dependency all check out). The decision is not yet:
29,368 real lines of compiler and no asker for a cancho-hosted
compiler today. Revisit self-hosting.md when one exists, rather than
re-running this spike.