FRICTION
Status: running log, open since 2026-07. Each entry records what fought back while writing a real program; entries claiming closure carry
Pinned-by:commands run bymake friction-check. Barepath:linecitations are as-of their entry's date (see note below).
One line per moment the language got in the way while writing a real web server. Non-blocking by construction: anything that blocks the server earns a phase in plan.md instead. This file is a deliverable; fixing everything in it is not.
The program these notes came from is server/ — tycho-httpd, ~440 lines, serving server/www to a real browser. Everything below was hit while writing it, not imagined about it.
A bare
path:linein this file is as-of its entry's date. Measured 2026-08-13 on a fresh random sample: 7 of 15 no longer point at their subject, because every insertion intosrc/tychoc.cshifts everything below it. They are deliberately NOT being re-anchored — open-list item 10 has the measurement and why a mechanical pass would certify the wrong line rather than fix it. When you write a NEW citation here, anchor it:path@SYMBOLfor a definition, orpath:N@token. Both survive an insertion; a bare line number does not.
An entry that claims to be closed carries
> Pinned-by:, since 2026-08-22. The line is a shell command;make friction-checkruns every one of them, deduped and in parallel, and names the entry whose fix stopped holding. Write> Pinned-by: none -- <reason>when nothing can assert it (#86 is a timing claim). An entry may carry SEVERAL pins, each deduped separately across the file. An entry with no pin at all is REPORTED, never a failure; as of 2026-08-22 every closed entry carries one, so an unpinned entry now means a new entry arrived without saying what asserts it. Scoring these by hand took most of a session and the mapping from entry to lane was inference; this makes it the author's claim instead, and runs it.
Plan references, removed 2026-08. This file used to point every closure at the plan that closed it (
docs/internals/plan-*-DONE.md). Those archives were pruned; the pointers now say "the X plan" with no phase numbers.
The headline
The language has a good answer for fallible calls and the standard library does not use it. Option/Result are real types and or_return is a real postfix operator that unwraps or short-circuits (docs/spec/10-statements.md:76). Exactly 1 of the corelib's 386 functions returns an Option — io.read_line (corelib/io/io.ty:33). Every other fallible call in this server reports failure with a sentinel, and the sentinel is different each time:
| call | failure is… | also means |
|---|---|---|
net.accept | a negative int | — |
net.write, httpd.write_response | -1 | — |
net.read | empty bytes | EOF and idle timeout |
io.read_bytes | empty bytes | empty file and missing file and a directory |
path.safe_join | "" | — |
httpd.parse_request | method == "" | EOF and timeout and malformed |
net.set_read_timeout_ms | false | — |
Six spellings of "it went wrong", three of which are indistinguishable from a legitimate success. The server code reads as if x < 0 / if len(x) == 0 / if s == "" scattered through every IO path, and two of the phase's real bugs were exactly this collision: the server could not tell a malformed request from a hangup, and cannot tell an empty directory from a file. Nothing about the language forced that — or_return was sitting right there.
Status, 2026-07-26. The
net.*rows above are historical:core:net's fallible TCP calls now returnResult(T, net.NetErr)andnet.readdistinguishesEof/Timeout/Failed(the option-result plan). What that phase *measured* is that the win is confined to the ambiguous sentinels — converting an unambiguous one is line-for-line neutral — and that the conversion surfaced five new ergonomic gaps of its own, recorded below.Status, 2026-09-19 — and the note above was itself an incomplete fix. It retired the
net.*rows and stopped there, whileioandhttpdwere converted afterwards and nobody came back. Six of the seven rows are now historical, and the paragraph's headline number is wrong in the direction that matters:| the claim above | today | |---|---| | "1 of the corelib's 386 functions returns an
Option" | 560 functions; 60 returnResult, 2 returnOption| |net.accept/net.write/net.read|Result(T, NetErr)| |io.read_bytes|Result(bytes, IoErr)| |httpd.parse_request/httpd.write_response|Result(Request, ReqErr)/Result(int, NetErr)|Still sentinels, and deliberately:
path.safe_joinreturns""andnet.set_read_timeout_msreturnsfalse. Both are unambiguous — no successful join is empty, no successful arming is false — which is exactly the case the 2026-07-26 measurement found to be line-for-line neutral to convert.The finding this section records still stands as history and should not be deleted: it is why
core:resultexists and whyor_returngot used. What was false was leaving it in the present tense at the top of the file, where it tells every new reader that the standard library does not use the feature it now uses in 60 places. Found 2026-09-19 by auditing the conventions rather than the code — the same sweep as 115-117, and the same shape: a fix applied to some of the sites it covered.Also historical, from the option-result plan: the
io.read_bytesandhttpd.parse_requestrows.read_bytesreturnsResult(bytes, io.IoErr)— an empty file isOk,Err(NotFound)andErr(IsDir)are distinct — andparse_request/read_requestreturnResult(Request, httpd.ReqErr)withMalformed/Closed/Timeout/Failed, all four recorded as distinct incorelib/test/httpd.out. The missingunwrap_oris nowcore:result. The rows that remain true arepath.safe_join, theiowrite side,net.udp_*andnet.set_read_timeout_ms— each deliberately left on a sentinel that has exactly one meaning.Closed out, from the option-result plan. The headline's own worked example — the
read_headreimplementation in the phase-7 list below — is gone.server/callshttpd.read_request_capped(conn, MAX_HEAD)and acts on the cause it returns:Malformed→400,Closed→ no response and no log line,Timeout→408(or a quiet close for a keep-alive idle expiry),TooLarge→431. 19 lines of duplicated corelib loop and a localHeadstruct deleted;server/main.tyended the plan at 371 code lines, exactly where it started. The honest arithmetic: the application did not get shorter, it got *truthful*, and the ~100 corelib lines that bought it are recorded inplan.md's verdict. One item in this file is closed. Thestatitem is not, and is now known not to be an error-model problem at all.Closed out, from the option-result plan (added on a user directive after phase 3 called it done). The
statitem too:io.is_dir(p) -> Result(bool, IoErr)exists over a 4-linestat(2)shim, andresolve()asks it instead oflen(io.list(p)) > 0, so a directory redirects to its slash form whether or not it holds an index and a non-directory never does.server/main.tystayed at 371 code lines. The plan's Goal ("the two known wrong answers fixed") is met — and the half that needed a syscall cost 3% of the lines the half that needed a type did.Recount, 2026-07-30. "1 of the corelib's 386 functions" is the 2026-07-26 figure. At
53dd937the corelib is 406 package functions, 1 returning anOptionand 15 returning aResult— so the headline's *ratio* has moved even though its subject (onlyio.read_linereturns anOption) has not. See the verdict's 2026-07-30 postscript for what that fifteenfoldResultgrowth did and did not buy.Correction from the option-result plan. "Exactly where it started" was true for four phases and is no longer: phase 5 replaced the startup
--rootcheck's one wrong message with four accurate ones andserver/main.tyended at 380 code lines, +9. The plan's own final number is a *growth*, not a wash.
The score against this file, re-scored against the tree, 2026-07-26
Two plans have now been run *because* of this file: the Option/Result plan (archived: the option-result plan) and the plan that worked this file as a list (10 phases, head 536b257). This section is the second one's closing act, and it is a re-score rather than an update: every "CLOSED" note in this file was checked against the thing it names before the counts below were written — 89 content checks (does that function / flag / document / diagnostic exist at HEAD and say what the note claims?) and 8 live runs of the code.
No stale CLOSED was found. Two stale *OPENs* were, and they are named below.
The count
| region | closed | refused with a number | deliberately kept | open |
|---|---|---|---|---|
| Phase 7 — writing the server (12) | 10 | 1 | 0 | 1 |
| Earlier phases (15) | 4 | 1 | 6 | 4 |
Created by the Option/Result plan (16) | 13 | 1 | 2 | 0 |
| Created by this plan's nine phases (9) | 2 | 0 | 4 | 3 |
| C-level, no Tycho spelling (2) | — | — | 2 | — |
| Found by this re-score (2) | 0 | 0 | 0 | 2 |
| total (56) | 29 | 3 | 14 | 10 |
- Of the 12 phase-7 items: 10 closed, 1 refused with a number, 1 open. The
Option/Resultplan closed 2 (read_head,stat/is_dir); this plan closed 8 (\r, multi-line strings,argv[0],--root DIR,exit(0),reason_phrase,getpeername, theto_str/to_bytessandwich) and refused 1 with a measured number (the work queue, ~283 lines across 4 files). The one open item is the first one in the list —sendis a builtin andfn send(...)is accepted silently. Reproduced at HEAD by this re-score, and the reproduction found the fix's own mechanism already in the tree:fn die(s: string) -> intis rejected at the *definition* (error: 'die' is already defined) whilefn send(a: int, b: int) -> intcompiles and dies at the *call* (error: send(ch, v) takes a channel, got int). Sodie/exitare in the table the duplicate check consults and the channel builtins are not; the fix is small and the decision it needs — which builtin names are shadowable — is the reason it is still open. - Two stale OPENs, the mirror image of the thing this re-score was written to catch.
:232("there is nounwrap_or,is_ok,is_someoris_erranywhere") and:324("a socket read timeout is indistinguishable from EOF") were both closed by theOption/Resultplan and neither item line was ever struck through:corelib/result/result.tyhas all seven combinators, andnet.readreturnsErr(Eof)/Err(Timeout)as distinct values. Both closures are stated in the headline block at the top of this file and neither reached the item. A file where the summary and the item list can disagree needs the audit run in both directions. - The
Option/Resultplan created 16 items, not 6. Measured, not recounted from its prose:FRICTION.mdwent from 38 bullets atfd97211to 57 at796dd90, of which 3 are that plan's own score bullets — 5 items in its phase-1 section, 5 in phase 2, 3 in phase 3, 3 in phase 4. Its verdict said "created six new items of its own" and this file repeated the number. It was a 2.7× undercount, and it mattered: the 6 is what made "−2 original, +5 new" look like a near-wash instead of a plan that opened 16 questions to settle 4. - This plan created 9 (57 → 66 bullets), and settled 27 across ten phases — 24 closed, 3 refused with a number. Settled-to-created: 27 : 9, against the previous plan's 4 : 16. That is the one ratio that moved, and it moved because the items were worked as a list of costed questions rather than as evidence for a thesis.
server/main.ty: 380 code lines before this plan, 341 after — 39 lines shorter, the first reduction in two plans. The trajectory, verified commit by commit: 380 (796dd90) → 378 (phase 1) → 378 (1b, 2) → 380 (phase 3) → 376 (4) → 372 (5) → 341 (6) → 341 (7, 8, 9). One phase did nearly all of it:core:clilearning--root DIRdeleted a 59-line hand-rolled parser (-31).- What it cost.
src/tychoc.c+359 raw lines, +176 non-comment (11795 → 12154; 9161 → 9337 code) — over half of it phase 3's nested patterns. Corelib packages +57 Tycho code lines and +17 C shim lines; corelib test programs +111;docs/+590/−84 with one new document;scripts/+203/−8, which bought three gates that did not exist (scripts/entrypoints.sh, the source→doc half ofcheck_citations.py, the un-rottedbytes-rehomelane). Four goldens moved and all four are pure appends (+53 / −0). - The shape of the two plans is opposite and the numbers say so plainly. The
Option/Resultplan spent +182 library lines and the application got 9 lines longer. This plan spent +176 compiler lines and +74 library lines and the application got 39 lines shorter. Ten of this plan's 24 closures were compiler changes, and a compiler change is paid once and refunded at every call site; a library conversion is paid once and charged at every call site. That is the generalisation this file can now support with two data points instead of one.
What the previous plan's score got right
Its two analytical points survive the re-score intact, and both are corrected only for which plan's phase 5 is meant.
The stat closure is evidence *against* the Option/Result verdict rather than for it: it was closed by 4 lines of C and 10 of Tycho with no Option, no Result and no or_return involved, and it is the only one of that plan's two closures that changed what a client receives. The error-model work made failure *sayable*; the syscall made an answer *right*. Those are different kinds of win and this file was conflating them.
And its phase 5 (the Option/Result plan's — 796dd90, not this plan's) closed the directory-creation gap with io.make_dir / io.remove (35 library lines, Result(bool, IoErr) both, non-recursive on purpose) and deleted the os.system shell-out from corelib/test/io. It found no new language friction at all, and it cost the application +9 lines, all in one startup check, to replace a single wrong message ("--root is empty or not a directory", said for an empty directory, a plain file, a missing path and an unreadable one alike) with four accurate ones. Result made the distinctions *available*; spending them costs one branch per cause, and nothing about the error model makes accuracy free at the call site.
The real remaining debt — the open list, re-scored in place
Re-scored 2026-08-18 by PROBE, not by reading. Every entry below was scored by writing a program and compiling it; the verdict is the compiler's own message or the program's own output. Two moved.
| # | the claim | what a probe returned today | verdict |
|---|---|---|---|
| 1 | the whole corelib hashes messages held entire | corelib/sha256/sha256.ty now has init/update/final/final_hex; corelib/md5, corelib/hash, corelib/crypto have none | DOWNGRADED — true for 3 of 4, false for the one that matters most |
| 3 | compress.decompress cannot tell empty from corrupt | round-tripped empty → Ok len=0; a halved payload → Err; zero bytes in → Err | CLOSED |
| 5 | a bytes slice clamps, so it is not a bounds check | b[1:99] on a 3-byte value returns length 2, no trap | CLOSED — deliberate, now pinned by tests/bytes_slice_clamp.ty |
| 11 | a first --shim C file must hand-declare tycho_int | no generated header existed; 13 of 14 shims declared it themselves | CLOSED — corelib/tycho.h ships and tychoc passes -I |
| 12 | for does not destructure a tuple, and a tuple is not indexable | *"a for binds one name"*; *"can only index an array, a string, by…"* | CLOSED — deliberate, both halves pinned |
| 37 | sink cannot express a builder | *"'f' is consumed by a sink parameter but is mentioned 2 times"* | CLOSED — deliberate, now pinned |
| 48 | a subscript cannot read two fields of its receiver | make grid-check green, and its own report says the flat 2-D limit *"still refused"* | CLOSED — recorded as a judgement call in its own entry, and already gated |
| 56 | decimal.from_str fails open to a WRONG number | from_str("12.5x") returns 1.25 — not 12.5, not 0, not an error | CLOSED — the lax name is deprecated and every call site warns |
All eight are closed as of 2026-08-18. Two took code (11, 56). Four were already-made decisions filed as if they were open (5, 12, 37, 48) — the verdict existed in the spec, in a reject fixture, or in the source, and what was missing was that three of the four rules had NOTHING asserting them, so a silent change could have landed. Each has a pin now. 1 downgraded and 3 closed on the probe alone.
What none of this is: evidence the language is easy. Every entry here was found by its own designer writing his own program. The list being empty means the known edges are recorded and pinned, not that a stranger would not find ten more in an afternoon.
What this pass did NOT re-score: the other 86 numbered entries in this file, and the per-item prose below, which still carries its 2026-07-31 framing.
Re-scored 2026-07-31 against 3ddc8fd — 11 open items. 43 commits since the previous pass: core:signal, io.mtime / io.read_at / io.size, Last-Modified / 304, Range / 206 / 416, bounded worker retry, and a citation gate that grew four directions, ambiguous-anchor rejection and a path@SYMBOL form. One numbered item closed (item 7, the parallel for fan-out — its spec defect was corrected and its undocumented ceiling documented), one grew (item 1 is now three shims, because the plan that shipped core:signal added a fourth instance of the same defect), and two were added (items 11 and 12). Four unnumbered items elsewhere in this file closed as well; they are struck in place in their own sections.
The numbering is frozen on purpose. Sections below this one address items by number ("item 7", "Open list item 5"), so a closed item is struck where it stands and new ones are appended. The count above is the count of *unstruck* entries.
The paragraphs below are the previous pass's, kept as its record:
Re-scored 2026-07-30 against
53dd937— 10 open items. Every item below was re-verified against the tree at53dd937before it was scored: the compiler was run by hand on the shape the item describes, or the cited source was opened, or the gate/shim was invoked directly. All ten reproduce. Nothing on the previous list turned out to be already closed — but four of them are *bigger* or *cheaper* than the previous list said, and every citation on the list has been re-derived, because the old ones had drifted.
The 2026-07-30 list was ordered so the top was what to pick up first, and that order is preserved. The two appended items are not last in priority just because they are last in the list — item 11 is the cheapest thing here. The pick-up order is written out in full under "What moved this pass" below.
- ~~Three shims do not compile under
-std=c11(*Found by phase 1's gate sweep*)~~ — CLOSED 2026-07-31 at251ec39by the shim-gate phase, gate first.scripts/shim_check.sh/make shim-check/scripts/ci.shstep[3d/13]compiles everycorelib/*/*_shim.cstandalone under-std=c11, and the three failures were fixed by copyingcorelib/io/io_shim.c's_DEFAULT_SOURCEblock. The gate was proven to redden by reverting thesignalfix. Two things the item got wrong, both found by measuring:corelib/image/image_shim.cwas never a defect (its one error is a missingpng.h, item 9), and a first version of the gate that kept-pthreadwas silently green on the unfixed shim —-pthreaddefines_REENTRANT, which glibc turns back into_POSIX_C_SOURCE=199506L. Phase 1's evidence block has the numbers. The record of what the item said when open follows. still the cheapest thing on this list, and it is now three files. It was one, then two, then three, and the growth is the item. Re-measured at HEAD by invokingcc -std=c11 -cdirectly on all 12 shims (there were 11 at the previous pass): eight pass, three fail, andcorelib/image/image_shim.c's single "error" is only a missingpng.h(environmental, item 9 — the previous pass wrote "item 6" here, which was wrong).
corelib/net/net_shim.c— 4 errors (corelib/net/net_shim.c:55storage size ofhints,corelib/net/net_shim.c:59implicitgetaddrinfo,corelib/net/net_shim.c:60invalid use of undefinedstruct addrinfo,corelib/net/net_shim.c:61implicitfreeaddrinfo).corelib/tls/tls_shim.c— 9 errors of the same kind fromcorelib/tls/tls_shim.c:173.- NEW at this pass —
corelib/signal/signal_shim.c, 3 errors: storage size ofsa(corelib/signal/signal_shim.c), implicitsigemptyset(corelib/signal/signal_shim.c), implicitsigaction(corelib/signal/signal_shim.c).struct sigactionand its two helpers are POSIX, not ISO C, so this is the identical cause with a different header. This is the finding, and it outranks the fix. The signal plan wrote a brand-new shim, in a tree where this item had been open and named for two re-scores, and reproduced the defect — because no gate compiles a shim standalone, so nothing could tell it. The item is no longer "two files want three lines"; it is "every new shim will want them, and the tree has no way to notice". A gate that runscc -std=c11 -fsyntax-onlyovercorelib/*/*_shim.cwould cost about as much asscripts/entrypoints.shand would close the class rather than the instances. The fix per file is already in the tree four times — an#ifndef/#definepair with a one-line reason atcorelib/io/io_shim.c:10-11(_DEFAULT_SOURCE),corelib/os/os_shim.c:9-10,corelib/datetime/datetime_shim.c:10-11andcorelib/time/time_shim.c:1-2(_POSIX_C_SOURCE 200809L) — so it is 3 lines copied into each of 3 files, plus the gate. Left on scope four times now.
- ~~
spawn f(x)as a bare statement~~ — CLOSED 2026-07-31: the parser now names the rule.parse_stmttestsE_SPAWNahead of the generic bare-expressiondie_atand saysaspawnmust be bound to a task handle -- writet := spawn f(args), because the implicit join at scope exit needs a handle to wait on (a Task cannot be discarded). Measured before and after on the same scratch program; notests/diag/fixture pinned the old text (greppedtests/for it and found the string only indocs/internals/), so the corpus count is unchanged at 560. The record of what the item said when open follows. (*Earlier phases*) — reproduced verbatim again at3ddc8fdwith a scratch program:spawn work(1)giveserror: a statement must be a declaration, assignment, or call -- a bare expression has no effect(src/tychoc.c:4534, unmoved since the previous pass), which still never states the real rule — a task handle must be *bound* so the compiler can hang the implicit join on it. One line of diagnostic text at a known line. Open only because nobody has spent it, through two re-scores. - ~~ is not reachable from
docs/README.md~~ — CLOSED 2026-07-31:docs/README.mdnow lists it under "How the docs are organized", besidearchitecture.md. The real question behind it — nothing checks that a document is *reachable*, only that its links resolve — is not closed and is filed as item 13 below. A seventh site of item 5's class was found in the same file while adding the link and is recorded there. The record of what the item said when open follows. (*Found by phase 1's gate sweep*) — re-verified at the time:grep -c bootstrap docs/README.md→0. Sharper than the old entry: the index deliberately points at *directories* (docs/reference/,docs/guides/,docs/spec/,docs/internals/,docs/rfc/), so almost every unlisted file is covered by its directory — and is the only top-leveldocs/*.mdno index entry reaches, the other five (docs/architecture.md,docs/from-c-to-arenas.md,docs/thesis.md,docs/tutorial.md,docs/README.mditself) all being named. One link, plus the real question behind it:scripts/check_links.shchecks that links *resolve*, not that documents are *reachable*, so an orphan is invisible to every gate. Three files underdocs/internals/are additionally mentioned by no Markdown at all. - ~~The
sendcollision (*Phase 7*)~~ — CLOSED 2026-08-13: the definition is diagnosed now.fn send(a: int, b: int) -> intwarns where it is WRITTEN -- "sendcollides with the builtin of the same name -- every unqualifiedsend(...)calls the BUILTIN, so this procedure is unreachable by that name (§3.7). Rename it, or call it qualified aspkg.send(...)" -- which is the definition-time diagnosis this item asked for; the caller still fails, but no longer as the first sign. The record of what the item said when open follows. Reproduced at3ddc8fdwith two scratch programs, and every citation on this entry re-checked and still correct.fn send(a: int, b: int) -> intcompiles silently and dies at the *call* witherror: send(ch, v) takes a channel, got int, whilefn die(s: string) -> intis rejected at the *definition* witherror: 'die' is already defined. The reason is pinned: the definition-time duplicate check isif (sig_find(pr->name) || consts_find(pr->name)) die_dup_proc(...)(src/tychoc.c:9775), andsig_findsearchesg_sigs— which holdsdieandexitas real entries (src/tychoc.c:5685-5686, insidesrc/tychoc.c@register_builtins) but holds no entry forsend,recvorcloseat all; those three are recognised ad hoc during resolution (src/tychoc.c:7156,src/tychoc.c:7165,src/tychoc.c:7184). So it is not a table that omits three rows, it is three builtins that were never in the table. The code is ~1 line atsrc/tychoc.c:9775; the open part is the decision — which builtin names are shadowable — because landing it newly rejects any program definingsend/recv/close. Note the generic path a line above (src/tychoc.c:9769) consults the same two tables plusgeneric_find, so whatever is decided has to be written twice.
And the "corelib layering decision" this item wanted taken was already taken, four times: core:io, core:json, core:markdown and core:toml all import "core:strings" today, and corelib/strings/ carries no deps file, so the import costs no pkg-config dependency. Nothing was ever in the way. The record of what the item said when open follows.
Reproduced at 3ddc8fd: corelib/strings/strings.ty@ends_with exists and core:httpd still hand-rolls its own corelib/httpd/httpd.ty@has_ext rather than import the package for one predicate. core:httpd's imports are core:net and core:result and nothing else (corelib/httpd/httpd.ty:42-43), which is the shape the decision is about. Not lines — a corelib layering decision about whether a leaf package may depend on core:strings. Note the precedent that has since landed: core:io *dropped* a dependency (core:path) when a syscall made it unnecessary, so the tree's current direction is fewer inter-package edges, not more. (The old entry cited has_ext at corelib/httpd/httpd.ty:292; it is 40 lines further down now, which is why this citation is a path@SYMBOL — the definition has a name and its line number was only ever a record of how much prose sat above it.)
- ~~
parallel forcaps concurrency atmin(N, ncpu)and nothing warns (*Earlier phases*)~~ — CLOSED 2026-07-31 at3ddc8fd, by the spec catching up with the compiler. Both live halves are answered indocs/spec/, verified by reading it:
- The 64-chunk ceiling is documented.
docs/spec/13-concurrency.md:81-83now reads "the reference implementation usesmin(ncpu(), N)chunks, MAY expose an override (TYCHO_THREADS), and MAY impose a fixed upper bound on the chunk count — the reference bounds it at 64, so above 64 the fan-out is narrower thanncpu()reports". The old text's "usesncpu()chunks" — false on both counts, theminand the cap — is gone. The compiler side is cited anchored from the spec's own provenance block,src/tychoc.c:13940@_pk > 64, so the gate now polices it. ncpu()'s false definition is corrected, which was the other half:docs/spec/16-builtins.md:249states outright that it is "the *requested* worker count, not the width aparallel forwill actually use" and that "a program that sizes a buffer or a work split fromncpu()MUST NOT assume that many chunks run".- The warning half stays uncosted and that is now a settled non-item, not a gap:
Nis a runtime expression, so there is nothing static to warn from, and the prunner plan measured that the cap limits width without starving — 200 items came back 200 at every setting. What is left is item 8, which already carries it: the one thing the spec fix does not give anyone is a way for a program to *choose* the width. The record of this item, as it stood, follows — the numbers in it are measurements of past trees and stay. - Reproduced live, not merely re-read. Four iterations of an equal fixed workload:
TYCHO_THREADS=4→ 222 ms,TYCHO_THREADS=2→ 433 ms,TYCHO_THREADS=1→ 853 ms. All N iterations *do* run (the reduction total is identical at every width); what is capped is how many run *at once*, so an iteration chunked behind one that never returns never starts. Three things the old entry did not know: - The behaviour is now specified, which it was not: "the iteration space is split into chunks; the reference implementation uses
ncpu()chunks and MAY expose an override (TYCHO_THREADS)". *(That is the pre-fix wording of §22, quoted as it stood on 2026-07-30;docs/spec/13-concurrency.md:81-83is the corrected text and no longer says this.)* - The width is now readable from Tycho:
ncpu()is a registered builtin (src/tychoc.c:2332@ncpu, lowering atsrc/tychoc.c:8755@tycho_ncpu), so a program can at least ask. Measured on this box:ncpu()→ 16. - There was an undocumented hard ceiling of 64 chunks —
if (_pk < 1) _pk = 1; if (_pk > 64) _pk = 64;(src/tychoc.c:14607, insidesrc/tychoc.c@gen_parfor) — whichdocs/spec/13-concurrency.mddid not mention, so on a box with more than 64 CPUs the spec's "usesncpu()chunks" was false. That half is a ~1-line spec fix and should be split out and taken — *it was, and closing it is what closed this item.* The warning half remains uncosted, becauseNis a runtime expression and there is nothing static to warn about. Runtime detail atruntime/tycho_rt.c@tycho_ncpu. - Confirmed and bounded by measurement, 2026-07-31 (the prunner plan, a real
parallel forprogram rather than the server that never used one). The 64 is exact and it was forced, at K=200 jobs of 50 ms each:TYCHO_THREADS=32→maxconc=32,=64→maxconc=64,=100→maxconc=64withncpu()reporting 100. The cap limits width; it does not starve — 200 items came back 200, unique, at every setting, which is the half the entry left open ("an iteration chunked behind one that never returns never starts" is true only for a body that can fail to return, and a terminating body is never at risk). At the real workload — 560 jobs,ncpu()= 16 — the cap is not in the picture at all. So the live half of this item is now just the undocumented ceiling andncpu()'s own false definition above it, split out as a carried-forward phase. *(It was taken, and both halves are the CLOSED note at the head of this item.)*
- ~~No direct spelling for N workers (*Earlier phases*)~~ — CLOSED 2026-08-13, BOTH halves, and (b) needed no type-system change at all. (a) is
parallel(W) for …, 1..64, refused as a literal outside that range and aborting at run time when computed (b188feb2). (b) — "N long-lived workers, each carrying its own identity" — turned out to be the loop variable the counting form already binds:parallel(N) for wid in 0..<N:gives each of the N chunks its ownwid. Probed directly (sum of ids over 4 workers = 6 = 0+1+2+3), and then PROVED ON THE PROGRAM THE ITEM CITED:server/main.ty's recursive fan-out — worker k spawning worker k+1 into a frame-local — is gone, replaced byparallel(n) for wid in 0..<n:, andmake server-checkstarts the real server and passes. The item asked for task handles in a container to get here; nothing in this tree needs them for this.
What the item's evidence really wanted is worth recording: server's wid is used for ONE thing, the log prefix "w" + str(wid) — not a shard key, not an index into per-worker state. A want that reads as "the type system must grow" was a label in a log line.** docs/rfc/parallel-for-width.md carries what shipped and what it asserts — including why the emitted _pk is read by tests/conc/run.sh: no output can show the width was honoured while a worker cannot observe its own identity, which is (b). The original costing follows.
COSTED 2026-08-13, and half of it was never a type-system change: The channel-drain form already lowers to parallel for __pw in 0..<ncpu() with the width synthesised as an ordinary E_CALL node in r_stop — the same slot a user's 0..<N fills — so want (a), "the program cannot choose N", is a grammar slot plus three call sites (src/tychoc.c@task_container_err is not among them), not a new type. Want (b), per-worker identity, stays open AND got harder to spell the same day: the natural parallel(4) for x, wid in work: collides with the one-binder rule gated by tests/reject/for_two_binders.ty, and both neighbour languages spend a second binder on the index. The RFC has the spelling, the four decisions it needs, the gates that can redden, and why a fixture that COUNTS workers is the load-bearing one. The record of what the item said when open follows.
Reproduced at 3ddc8fd with a scratch program: hs := [spawn work(1), spawn work(2)] is refused with tychoc: a task handle cannot be stored in a container or aggregate -- wait(t) first (src/tychoc.c@task_container_err, fail-closed at the type-intern choke points so a task cannot escape and be waited twice or never). server/main.ty@worker still pays the recursive fan-out — worker k spawns worker k+1 into a frame-local, then runs its own accept loop. An array of handles is a type-system change, not an item-sized fix. Uncosted, and still the honest core of what is left. *(This entry cited src/tychoc.c:934 and server/main.ty:569-571 at the previous pass; the first drifted by one line and the second by 440, because server/main.ty roughly doubled — 1088 lines now. Both are path@SYMBOL here, which is why they will not drift again.)* NARROWED, 2026-07-31, and the item reads stronger than it is (the prunner plan). The first program in this tree to actually run a worker pool started 16 workers in one line and stored no handle: parallel for is a direct spelling for N workers, and parallel for x in ch: — specified at docs/spec/13-concurrency.md:99-100, worked at docs/reference/concurrency.md:86-112, fixtured at tests/conc/parfor_chan.ty:11 — is a direct spelling for a *bounded pool over a queue*, the exact shape this item says has none. So the premise "it is either N hand-written spawn lines or a recursive fan-out" is false for N = ncpu, which is the N most programs want, and an array of task handles is not what a worker pool needs. What survives is one sentence and it is sharper than the original: the program cannot choose N. ParallelFor (docs/spec/02-grammar.md:255-256) has no width slot in the grammar, so the only knob is TYCHO_THREADS, read once per process in runtime/tycho_rt.c@tycho_ncpu — a fixture runner that wants -j 4 on a laptop and -j 32 in CI cannot say so from inside the language, and must be *launched* differently instead. server/main.ty@worker's recursion is still evidence, but for a different want: N long-lived workers each carrying its own wid, which parallel for genuinely cannot express because a chunk's identity is not observable. Two items, not one, and only the second needs the type system. Re-checked at 3ddc8fd and the narrowed form holds in both halves. The grammar still has no width slot — docs/spec/02-grammar.md:255-256 is two productions, a counting one over a literal 0..<Expr and a foreach one over a bare name, and neither carries a count. And server/main.ty still spells its pool as a recursion carrying wid, which the file now uses for more than logging: the shutdown registry is indexed by wid - 1 (server/main.ty), so worker identity is load-bearing there and parallel for remains unable to supply it.
corelib/test/imageis skipped without libpng (*the friction plan phase 7, non-blocking*) — confirmed environmental and confirmed *live*:corelib/image/depsnameslibpng,pkg-config --exists libpngstill fails on this machine at3ddc8fd, andcorelib/run.sh:39printsskip <name> (missing dependency: ...)and continues. Its golden therefore asserts nothing here. Not closable in-tree — it is a property of the machine, and the skip is the deliberate design that keepsmake cigreen on platforms without the lib. Listed so nobody re-derives it a third time.- ~~This file's own coordinates drift silently, and no gate can see it~~ (*phase 10*) — the drift REPRODUCES and the proposed fix is UNSAFE. Verdict recorded 2026-08-13; nothing mechanical was run.
Re-measured on a fresh random sample of 15 bare path:line refs at e9d05224: 7 no longer point at their subject (2026-07-31 measured 11 of 15), so the defect is real and undiminished.
But "a mechanical pass to anchored form, after which the gate polices them" — what this item asks for below — would make it worse. An anchor is checked by CONTENT: path:N@token passes when token appears on that line. Anchoring a ref that has ALREADY drifted therefore freezes the WRONG line and hands it a green gate, where a stale line number at least reads as wrong the moment someone opens it. Measured over all 128 bare refs in this file: 58 sit on a line carrying a token unique in its file, so 58 anchors could be generated — and nothing distinguishes which of those lines are still the intended subject, because that lives only in the author's head. Deriving the symbol from the surrounding prose instead is no better: it collapses distinct refs onto one anchor (corelib/net/net_shim.c lines 84, 88, 89 and 90 all yield @hints) and picks generic words (corelib/io/io.ty:33 yields @Option).
What is safe is the FORM, at the moment of writing, and it is already the house rule: cite a definition as path@SYMBOL with no line number, or write path:N@token yourself, where you know the subject. Both are position- independent or content-checked, so neither can drift into a green lie. The ~105 existing bare refs stay as they are, as-of-their-date coordinates in a historical record — see the note at the top of this file. The record of what the item said when open follows. Fifteen path:line citations were opened at HEAD and checked against what this file says is there: 11 of the 15 no longer point at their subject. All eleven are into src/tychoc.c — the \r escape set, the literal-intern emit site, the adjacent-literal join, is_place, the exit builtin registration, copy_into's T_BYTES case, instantiate_generic, the zero-cost reinterpret, detect_package, the bytes representation and the channel-handle type syntax — because every compiler phase shifts everything below it, and src/tychoc.c is now 754 KB. The four that survived are all into files that barely moved (corelib/httpd/httpd.ty:420, corelib/httpd/httpd.ty:229, server/main.ty:634, runtime/tycho_rt.c:701) — which is the shape of the problem: the citations that matter most are the ones most likely to be wrong. scripts/check_citations.py cannot catch it by construction; it verifies anchored path:line@token refs against the token and only bounds-checks bare ones, and every bare ref into a 12k-line file stays in bounds forever. This file has ~85 path:line citations and 4 anchor markers. The fix is a mechanical pass to anchored form, after which the gate polices them. The same defect in a second dimension was found by this re-score and repaired rather than listed: 51 "plan.md phase N" references pointed at a plan.md that is now an unrelated plan — fixed with one definitional note at the top of this file, because rewriting 51 references would have gone stale again at the next archive. That asymmetry is the lesson: a coordinate that names a moving target should be replaced by one that names a stable one, not repointed.
What moved and what did not (2026-07-30, kept as that pass's record). Items 1, 2, 3 and 5 are lines and links with the work already identified — roughly a day between them, and item 1 has a known-good pattern to copy four times over. Items 7 and 8 are the concurrency pair and are still the honest core: one wants a type-system answer, the other wants a warning there is nothing static to warn from. That sentence did not survive being tested — see the 2026-07-31 section below. Item 8's type-system answer is not what a worker pool wants (it stores no handles), and item 7's danger is bounded by measurement; what is left of the pair is one missing width parameter. Item 6 wants a decision, not lines. Items 9 and 10 are properties of the environment and of the file itself. The list did not shrink, and that is the finding — sixty commits of real language work went past this list without touching it, because every one of them was driven by
new_ideas.mdand by the loop and array plans instead. A list nothing is pulling from does not get shorter on its own.
What moved this pass, 2026-07-31 at 3ddc8fd — and the previous paragraph's finding was overturned. It said a list nothing pulls from does not get shorter. Something did pull from it: item 7 was closed, and it was closed *because* the concurrency work went looking for what the list said was open. Four more of this file's unnumbered items closed the same way. So the mechanism works; what the previous pass had actually measured was a batch that happened to be aimed elsewhere.
Pick-up order, cheapest first — SPENT 2026-08-13. Every item this ordering names is now struck above: the last two were item 6 (withdrawn, has_ext is not ends_with) and item 8 (closed in both halves, the width slot plus the loop variable). The order is kept as the record of how the batch was scheduled, and because entries below address items by number.
It is not a to-do list, and it reads like one. A numbered line here is an ITEM REFERENCE (1. **Item 11** — … orders item 11 first), not an open item; a detector that greps this section for unstruck numbers reports nine open items and is wrong every time — mine did, three times in one session, before reading the header two lines up. The open items are the numbered N. entries in the list ABOVE this paragraph, and as of 2026-08-13 there are none.
- Item 11 — one paragraph plus a provenance line in
docs/spec/14-ffi.md, and it is the only open item where the tree already has three working instances and no written rule. Minutes. - Item 2 — one line of diagnostic text at a line that has not moved in two re-scores.
- Item 3 — one link in
docs/README.md. - Item 1 — 3 lines into each of 3 files with a pattern to copy, *plus* the gate that stops the next shim repeating it. The gate is the part worth the time.
- Item 5 — ~15 lines of comment across 6 files, all six re-read and located here.
- Item 4 — ~1 line of code, gated on a decision (which builtin names are shadowable).
- Item 6, item 12 — decisions, not lines: a corelib layering rule, and a naming question in
core:cliwhere the current name is documented and the trap is that the documentation is what you have to read to avoid it. - Item 8 — the last genuinely hard one. A width slot on
ParallelFor, or task handles in a container; only the second needs the type system, and neither is item-sized. - Items 9, 10 — a property of this machine and a property of this file. Neither is closable by writing code, and both are listed so nobody re-derives them again.
Spent, 2026-07-31 — the pick-up order above is now history. Items 1, 2, 3, 5, 11 and 12 are struck: six of the eleven then-open entries, in one plan of two phases, and the nine lines above covered every one of them. What is left open is items 4, 6, 8, 9, 10 and the three this pass created (13, 14, 15) — and none of the survivors is a line of code. Items 4 and 6 want decisions, 8 wants a design, 9 and 10 are properties of the machine and of this file, and 13 wants a definition of "reachable" that the cheap version of the gate does not have. That is the real finding of the batch: the cheap end of this list was cheap, and clearing it leaves nothing that a bigger effort budget alone would close.
And the ordering was by lines, which is again not effort. The two doc-only items (3,
- ran to roughly their estimate. Item 5's "~15 lines across 6 files" cost more than
item 11's paragraph, because rewriting an expired reason means first establishing what the surviving reason is — which took a compile probe and disproved a sibling comment (item 14). Item 12 was a decision and cost the least of all: the answer was already in the tree. And item 2, "one line of diagnostic text", was by far the most expensive — not for the line, but because inserting seven lines at src/tychoc.c's line 3574 staled 80 anchored citations across 16 files. The line was minutes; its blast radius was the work. Nothing in a list ordered by lines can see that, because the cost is a property of *where* the line goes, not how long it is.
Re-scored against a real concurrent program, 2026-07-31 (head 0d0dacc)
Everything above about concurrency was written from server/, and grep -n parallel server/main.ty is empty — that program used spawn/wait and never wrote a parallel for at all. Items 7 and 8, which the score above calls "the honest core of what is left", were therefore inference from a program that did not exercise the construct they name.
tools/prunner/main.ty does. 479 lines; it runs the whole 560-fixture corpus over a bounded channel with a parallel for pool and a spawned fan-in collector, and its report is byte-identical to sh tests/run.sh's at 7.62x (471,695 ms → 61,867 ms, maxconc measured at 16 = ncpu()). Evidence under the prunner plan. Items 7 and 8 are re-scored in place above. What is new is below.
- The finding that outranks every complaint here: it compiled first try, twice out of two. Phase 1's ~240-line pool (two channels, a spawned producer, a spawned collector, a
parallel forwithselect/recv, a subprocess call inside the body) and phase 2's 479-line rewrite (seven lane judges,os.run, structs-with-strings through a channel,sort.asc) each built with no errors and no warnings on the first./tychoc. This file's picture of concurrent Tycho as a fight is not what either phase found, and the expectation of a fight was set *by this file*. "Concurrency is the best thing in the language" (What was good, below) now has a second, independent data point from a workload that is not a web server. - A restriction improved the design, which is the one outcome this file has no category for.
docs/spec/13-concurrency.md:100permits aparallel forbody exactly one outer-scope write — a+/*reduction onint/float— anddocs/spec/13-concurrency.md:110-111makes any other write a compile error (tests/conc/reject/parfor_push.ty: "parallel for cannot mutate captured variable 'xs' in place"). A fixture runner's results are per-item, not a scalar sum, so they must leave through a second channel carrying an explicit index. That was not a choice — and it is exactly what makes a lost job detectable: an index that never arrives printsFAIL <name> (NO RESULT — the runner lost this job)and an index filled twice is rewritten toDUPLICATE RESULT. The plan's stated worst case, a parallel runner reporting green over lost work, cannot happen quietly, and the restriction is why. - The affine-channel rule cost nothing, at two scales.
docs/spec/13-concurrency.md:127("cannot be returned, stored in a container, or captured") forces the pool to be a scope rather than a reusable object, andrun_pool(js, tmp) -> [Res]still reads as an ordinary function: channels interior, array of results out. It read the same way at 243 toy jobs and at 560 real ones. The friction would start if two call sites wanted to share one pool, and nothing in this tree does. - A struct carrying heap strings crosses a channel exactly like one carrying ints, and this is the single thing that made the work tractable.
Res{idx, ok, name, reason, t0, t1}— two strings per message, 560 messages — needs no encoding, no parsing back and produces no aliasing surprise. Probed before it was relied on, half expectingint-only. A channel restricted to scalars would have forced a side table keyed by index and every attribution bug that implies. - ~~NEW — §22 of the spec does not describe the construct every per-item worker pool depends on.~~ CLOSED 2026-07-31, verified at
3ddc8fdby reading §22.docs/spec/13-concurrency.md:86-90now carries the carve-out as a normative paragraph of its own: "Each chunk's captured values are deep-copied into it, with one exception: aChannel(T)capture is a scalar handle and is passed by value. Every chunk therefore shares the one queue rather than receiving a private copy of it. This is not a detail of the reference implementation — it is what makes a worker pool expressible at all, and an implementation MUST NOT deep-copy a captured channel." That is theMUST NOTthe entry said a second implementation would otherwise have to guess at, and it is in the normative document rather than only in the guide. The entry as it stood:sendon a captured channel from inside aparallel forbody is what routes results out, and within §22 (docs/spec/13-concurrency.md:76-121) the only mention of a channel is that it may be the foreach *source* (docs/spec/13-concurrency.md:91-92). Worse, the section states that "each chunk's captured values are deep-copied into it" (docs/spec/13-concurrency.md:81-82), which read literally would give every chunk its own private queue — the opposite of what the compiler does and of what 560 jobs crossing one channel proves. The rule is written down, but only in the non-normative guide: "the chunk tasks share the captured channels (a channel is a scalar handle, passed by value — not deep-copied per chunk)" (docs/reference/concurrency.md"the chunk tasks share the captured channels"). A conformance gap, not a language defect — the implementation is right and the normative document is silent where a second implementation would have to guess. ~3 sentences in §22 plus a carve-out beside the write rule. Filed as a carried-forward phase. The estimate was right: what landed is one paragraph. - ~~NEW —
iter.mapcannot change the element type, and the lambda syntax hid it.~~ CLOSED 2026-07-31, verified at3ddc8fd:corelib/iter/iter.ty:14is nowfn map(xs: [$T], f: fn($T) -> $U) -> [$U], two type variables, so[Res] -> [int]is expressible. And it answers the question the entry flagged as unverified — the entry said "whether the inference reaches a function-typedfn($T) -> $Uparameter is not verified, and is the thing to check before costing this". It does; that was the whole cost.filteris unchanged (corelib/iter/iter.ty:5,fn($T) -> int), as the entry predicted. The entry as it stood:corelib/iter/iter.ty:8isfn map(xs: [$T], f: fn($T) -> $T) -> [$T]: one type variable, so[Res] -> [int]is not expressible through it. Measured — a correctly-spelled lambda getserror: argument 2 of 'iter__map' is fn(Res) -> int, which does not fit the parameter pattern. What made this cost time is that the *first* attempt reached formap(r => r.idx, rs), which is not the lambda spelling (docs/spec/09-expressions.md:187isfn(params) -> R: expr) and giveserror: expected ')'— so the parse error was read as "lambdas are the problem" and the real signature was never reached. The extraction became a scatter loop, which is better code here anyway, so this cost nothing in the end. Known cost: a second type parameter onmap. Multi-parameter generics work (corelib/result/result.tytakes three), but that is a *value* parameter — whether the inference reaches a function-typedfn($T) -> $Uparameter is not verified, and is the thing to check before costing this.filteris unaffected (fn($T) -> intalready). - NEW, small —
cli.hasanswers a narrower question than its name, and there is no diagnostic possible. PROMOTED 2026-07-31 to open list item 12 — still open, still unchanged at3ddc8fd(nosuppliedexists), and numbered there because an unnumbered bullet in a dated section is not something anyone picks up. *(Item 12 is struck as of 2026-08-13; this bullet's "still open" is as-of its own date.)* A bare--statslands inCli.flags, notCli.keys, socli.has(c, "stats")returns false andcli.flag(c, "stats")returns true; measured, both spellings compile, both returnbool, and the failure is a missing line of output with nothing printed. It is not a defect — the doc comment atcorelib/cli/cli.ty:138says outright "Was optionkey(a--key=value) supplied at all?", andhas(corelib/cli/cli.ty:139) /flag(corelib/cli/cli.ty:146) scan different vectors on purpose. The trap is the name:hasreads as the general question and answers only the valued half. A decision (rename tohas_value, or asupplied(c, name)that scans both), not lines — and the only thing here that failed *silently*, which is why it is written down despite being small. - NOT an item, recorded so it is not filed as one. A multi-line
if A or B or\n C:does not continue and giveserror: expected an expression— but wrapping the condition in parentheses works, verified, and implicit joining inside(/[has existed sincetests/multiline_literals.ty(see the phase-7 multi-line-string entry below). The language has the feature; the author did not reach for it. - ~~NEW, one line — the guide's bounded-fan-out section points its reader at the desugaring.~~ CLOSED 2026-07-31, verified at
3ddc8fd:docs/reference/concurrency.mdnow closes the section by naming both fixtures with the right framing — "Worked example of the sugar:tests/conc/parfor_chan.ty…tests/conc/workers.tyis the *manual* form the sugar replaces … read it to see what the sugar saves, not as the way to write this" (docs/reference/concurrency.md:108-112). The section also absorbed item 7's spec fix a few lines above, stating that the chunk count ismin(ncpu(), N)and capped at 64 (docs/reference/concurrency.md:104-106). One wrong pointer cost a plan its recon; naming both fixtures is what fixed it. The entry as it stood:docs/reference/concurrency.md:86-104introducesparallel for x in ch:, explains that it "desugars to aparallel forover0..<ncpu()" and then closes with "Worked example:tests/conc/workers.ty" (docs/reference/concurrency.md:104) — andtests/conc/workers.ty:2-3says of itself that it is "the patternparallel for x in ch:sugars over; written here with today's primitives". The fixture that demonstrates the sugar istests/conc/parfor_chan.ty:11and the guide does not name it. This is what actually happened rather than a hypothetical: this plan's recon readtests/conc/workers.ty, copied the manual0..<N+selectidiom intotools/prunner/main.ty:358-363, and never learned the sugar existed until phase 3 went looking. The sugar accepts asendbody — verified with a scratch program, 200 jobs sent, 200 received — so it would have removed the0..<nsizing, theselectscaffolding and thesent != ninvariant. Nothing in the language was in the way; one wrong pointer in a guide was. Fix: name both fixtures atdocs/reference/concurrency.md:104. - The work-queue refusal below is confirmed a second time, and at the same time shown to be workload-shaped. That entry (phase 7, refused with a number) established that an MPMC work queue is writable today with no compiler change; this program is an independent instance at 560 jobs and 16 workers. But its *conclusion* — that the queue buys nothing, because the cap is a worker blocking in
recv(2)— is a property of the server's workload, not of the queue. Here the jobs are finite, CPU-and-subprocess bound, and every one terminates, so the same construct is the entire 7.62x. Same shape, opposite verdict, and the difference is whether a work item is guaranteed to finish. Neither result generalises without that qualifier.
What this program did NOT touch, so that nobody over-reads the evidence. It is *one* shape: a bounded pool over a channel with a fan-in, over jobs that all terminate.
selectunder contention is untested. Bothselects intools/prunner/main.tyhave exactly onerecvarm and nodefault:and noclosed:. Nothing here exercises arm ordering, and the spec's "select is not fair, so an earlier-listed ready channel is always preferred" (docs/spec/13-concurrency.md:165-167) was never in play.- No long-lived workers. Every worker is a
parallel forchunk that ends with the loop; none outlives the pool's scope. The server's shape — a worker owning a connection for its whole life — is exactly what this says nothing about, and it is the shape item 8 still has a live complaint about. - No failure mid-pool. Every fixture failure here is a *judged verdict*, a value carried in a message. No job aborted, no
die()ran inside a chunk, nothing sent on a closed channel. What happens to the other fifteen chunks when one aborts is unknown from this evidence, and it is the first thing to test before this pattern is trusted where a work item can crash. - Backpressure was never observed biting. Capacity 16 with a collector draining continuously; the results channel filling and parking a worker did not happen, or at least was not measured.
- Nothing above 64 workers as a real workload. The 64-chunk probe used synthetic 50 ms sleeps; the real corpus ran at
ncpu()= 16, nowhere nearsrc/tychoc.c:14607. - No nested parallelism — no
parallel forinside a spawned task, and no pool inside a pool.
Phase 7 — writing the server
Phase 1 of the Option/Result plan — converting core:net
Found while converting core:net's fallible TCP surface to Result(T, net.NetErr) and rewriting server/, corelib/httpd, both corelib tests and both examples against it.
Phase 2 of the Option/Result plan — the combinators, io.read_bytes, httpd.read_request
Found while adding core:result and converting the two genuinely ambiguous calls.
Phase 3 of the Option/Result plan — acting on the cause, and deleting read_head
Found while moving the cap and the raw buffer into core:httpd so server/ could stop reimplementing the read loop.
Phase 4 of the Option/Result plan — the missing syscall
Found while writing io.is_dir and its test.
- ~~
Option/Resultphase 4 — nothing in Tycho can create a directory. Verified absent, not assumed:docs/spec/16-builtins.md§29.10 lists five filesystem/time builtins (read_file,write_file,list_dir,clock,now) and none of them makes a directory, andmkdir/make_dir/create_dirreturn zero hits acrosscorelib/,src/tychoc.candruntime/. There is no remove either. Socorelib/test/io— the test for astat(2)wrapper — has to build its empty directory withos.system("rm -rf … && mkdir -p …"): a corelib test depending on/bin/shto set up a filesystem state the corelib itself cannot reach. The asymmetry is the finding: the library can now *classify* a directory but not *make* one.~~ CLOSED, the option-result plan.io.make_dir(p)(mkdir(2), no-p) andio.remove(p)(remove(3), one entry, never recursive) both returnResult(bool, IoErr)whereOk(true)is "changed it" andOk(false)is "already how you asked" —make_dirsplitsEEXISTintoOk(false)(already a directory: goal met) andErr(Exists)(a file is in the way: goal unreachable), which is exactly the ambiguity test this plan was built on.corelib/test/iono longer importscore:osand therm -rf && mkdir -pline is gone. A non-empty directory isErr(Failed), which is the property that keepsio.removefrom beingrm -rfbehind a corelib name. - ~~
Option/Resultphase 4 —io.existsandio.is_dirnow answer overlapping questions by different means, and the cheaper one is the newer one:existslists the whole parent directory (O(entries), and it cannot see a./..-only leaf) whereis_diris onestat.resolve()ends up calling both on the same path. Astat-backedexistsis the obvious follow-on and was refused on scope, but the general shape is worth recording — a missing syscall does not just block the question it names, it leaves *neighbouring* answers implemented the long way round.~~ CLOSED, the friction plan, and the follow-on was bigger than the swap.existsis nowiox_stat_kind(p)and two comparisons (corelib/io/io.ty:619-623), the same shim callis_diruses; it still fails closed, sofalsemeans "statcould not say yes" — the old behaviour too, since an unlistable parent yielded no entries.corelib/test/io.outis byte-identical before and after, which is the proof the swap changed the means and not the meaning. Two things the entry did not predict. (1)core:iolost a dependency:path.base/path.dirwere needed *only* by the oldexists, soimport "core:path"is gone and the module written up as "the first corelib module to COMPOSE other core modules" now composes one, not two — a stale claim in three places, all corrected. (2)resolve()'s double call did not just halve, it collapsed: making the second call astatis what made the pair visibly redundant rather than merely ugly, and the two calls are now ONEmatch io.is_dir(fsp)reading all three answers off the Result (server/main.ty:399-404) —Ok(true)→301(or404whendir_formalready appendedindex.html, i.e. a directory *named*index.html, which used to be a200→read_bytes→Err(IsDir)→404: same status, one syscall fewer, no wrong intermediate),Ok(false)→200,Err(_)→404. Per request for a real file: 2 syscalls (an opendir/readdir walk plus a stat) → 1 stat.corelib/io/io.ty98 → 93 code lines,server/main.tyshorter as well. The entry's own closing sentence turned out to be the useful half and to run both ways: a missing syscall leaves neighbouring answers implemented the long way round, and adding it does not fix them — someone has to go back and delete the long way round, which is a second, separately-scoped piece of work that is easy to leave undone because nothing is red. Option/Resultphase 4 — reordering two guards to make room for a new one silently changed a security answer: hoistinghidden_segment(path.clean(rel))above theindex.htmlappend madeGET /return 403, because for the root targetrelis""andpath.clean("")returns"."(corelib/path/path.ty:98-99), whichhidden_segmentreads as a dotfile. Nothing in the compiler or the corelib could have caught it —clean("")returning"."is documented POSIX behaviour and both spellings type-check identically. It was caught by the live matrix (50-request flood 0/50 200), which is the argument for keeping that matrix.
Two defects that were not expressible in Tycho at all
Both were found by running the server, both are C-level socket properties with no Tycho spelling, and both therefore earned a corelib/net/net_shim.c fix rather than a line in this file. Recording them here because *the reason they were unreachable* is the ergonomic finding.
SIGPIPEkilled the whole server. Nothing incorelib/,runtime/orsrc/mentioned SIGPIPE. One client that sent a partial request and closed without reading terminated the process — every worker, every in-flight connection — measured aspoll()=-13. A Tycho program cannot fix this: signal disposition is process-wide with no Tycho surface, andnetx_writeloopssend()internally, so even a single logicalnet.writecan issue several syscalls. Fixed withMSG_NOSIGNAL/SO_NOSIGPIPE; the server now survives 100 consecutive hostile disconnects. RE-SCORED 2026-07-31 — the general claim inside this entry has stopped being true, and the entry's conclusion is unchanged by that. Tycho does have a signal surface now:core:signal(docs/spec/18-library.md§32.27) installs aSIGTERM/SIGINThandler whose only action isshutdown(fd, SHUT_RDWR)on a listening socket. It is two functions wide and cannot set a disposition — there is nosignal.on(sig, handler)and no way to spellSIG_IGN— soSIGPIPEremains unreachable from Tycho andMSG_NOSIGNALin the shim is still the only fix for it. What changed is the *precision* of the complaint: "signal disposition is process-wide with no Tycho surface" is now "no Tycho surface for signal disposition", which is the narrower and more useful statement of the gap.- Nagle cost 620× on every small response.
httpd.write_responsedeliberately sends head and body as two writes so the body is never copied — a Phase 2 optimization. With Nagle on, that second small segment waits for the peer's delayed ACK. Measured, same server, same bytes: 43.73 ms/req (23 req/s) with two writes vs 0.07 ms/req (14,465 req/s) with one.TCP_NODELAYfixes it and no Tycho program can set it. The lesson is sharper than the bug: a corelib change that saved onememcpycost three orders of magnitude, and nothing in the language or library could have surfaced that to the person who wrote it.
What was good
An honest account needs this half, and the good is not a consolation prize — some of it is genuinely better than the mainstream alternatives.
- Concurrency is the best thing in the language.
spawn/wait, no async colouring, no executor to configure, no locks anywhere in the server, and real parallelism: 1 / 4 / 8 client processes gave 12,456 / 41,046 / 79,712 req/s, linear in worker count. The whole pool is five lines. Compare what "8 worker threads sharing an accept loop" costs to write in C. Confirmed 2026-07-31 on a second, non-server workload, which matters because this bullet was written from a program that never usedparallel for:tools/prunner/main.tyruns 560 fixtures over a bounded channel at 7.62x (maxconcmeasured at 16 =ncpu()), and both of its drafts — ~240 lines and 479 lines — compiled with no errors and no warnings on the first attempt. See the 2026-07-31 re-score section above. - Value semantics made the response builder obviously correct.
r = httpd.with_header(r, ...)returns a new value, so a three-call chain has no aliasing question, needs no defensive copy, and cannot be wrong. Zero aliasing bugs across the whole phase. This is the part I would keep unchanged. path.safe_joinis exemplary corelib design. Fail-closed (returns""), handles both the absolute-path and the climb-out cases, and documents itself with worked examples including the answers. Thirteen traversal vectors —..,%2e%2e,%2E%2E%2f,....//,//etc/passwd,/%00,.git— all refused on the first attempt, with the whole defence being one call plus decoding in the right order.httpd.content_typedefaults toapplication/octet-stream, nevertext/plain, and says why in a comment. That is the correct call and the reasoning was written down where the next person will read it.- Diagnostics are precise when they fire — file, line, column, caret, and the source line quoted. The
sendcollision is a gap in *where* the check happens, not in the quality of the message. bytesbodies delivered byte-exact binary on the first try: a 480×270 PNG, a PNG-in-ICO favicon, and a 95 KB TrueType font, allcmp-identical to disk, including aHEADthat reports the realContent-Lengthand sends nothing.- It compiled on the second attempt — one rename — and then ran the entire abuse campaign (malformed request lines, binary junk, 60 KB headers, 2000 headers, slow-loris, RST-mid-body, 64-way floods, 6400-request runs) with zero runtime crashes once the two C-level socket issues were fixed. No use-after-free, no bounds abort, no leak, no data race across 8 threads sharing a listening fd.
- The corelib is unusually well-commented. Learning
path,httpd,netanddatetimewell enough to build against them was fast, and the comments were accurate — with one exception,httpd's old claim that an interior NUL truncates a string body, which Phase 2 measured as false. - Indentation-block syntax reads well at length.
server/main.tyis 440 lines and stayed readable without a formatter fight.
The honest verdict
Tycho can write a real web server, and the result is fast, safe, and readable. What it cannot yet do is make *failure* pleasant. The concurrency model, the value semantics and the diagnostics are all better than the median; the error model is worse, and it is worse by choice rather than by capability, because the language already ships Option, Result and or_return and the standard library uses them once in 386 functions. Adopting them across the IO surface would remove more friction from this file than every other item combined.
Postscript, 2026-07-26. That last sentence was acted on and it was wrong. See "The score against this file" above: five phases of adoption closed two of this file's twelve items, created six new ones (one since closed), spent 182 library code lines, and left server/main.ty nine lines longer than it started — 371 → 380 — with the growth coming from the phase that made a startup message *accurate*, not from the four that made failures *sayable*. The narrower claim survives intact and is the one to carry forward: where a sentinel is genuinely ambiguous, Result is a clear win; where it has exactly one meaning, converting it is line-neutral at the call site and pure cost in the package. The first sentence of this verdict — that Tycho cannot yet make *failure* pleasant — still holds; what changed is that the reason is ergonomics (unwrap_or, nested patterns, map_err, inference) rather than the absence of Option and Result from the type system.
Postscript, 2026-07-30 (head 53dd937). Two numbers in the verdict above have moved and the sentence they support has been overtaken by its own follow-through. Re-counted at HEAD by grep '^fn ' over the package sources (not corelib/test/): the corelib is 406 functions, not 386; 1 still returns an Option (io.read_line); 15 now return a Result. So "uses them once in 386 functions" is no longer the state of the tree — it is the state the file was written in, and the Result surface has grown fifteenfold since. More to the point: all four ergonomic gaps this postscript named as the *real* reason failure is unpleasant — unwrap_or, nested patterns, map_err, tuple inference — have since landed, each with its measurement in the CLOSED notes above. The verdict's diagnosis was acted on and, unlike its predecessor, it held: the four fixes cost 4, 116, 4 and 11 compiler or library lines respectively, and none of them made the application longer. What this file can now say with three data points instead of two is narrower and more useful than either verdict: converting a library to Result is charged at every call site; fixing the ergonomics of Result is refunded at every call site. The open list is no longer about the error model at all.
Earlier phases
Found by the friction plan's gate sweep, out of its scope
The signal plan, 2026-07-31 (head 7926da5) — closed for one case, narrowed for the rest
server/main.ty@stopped prints tycho-httpd: stopped after N requests and was unreachable. Nothing in the tree installed a SIGTERM or SIGINT handler, because Tycho had no signal surface at all, and server/run.sh asserted wait status 143 — it asserted the *absence* of clean shutdown and called that a passing gate. core:signal closes that. The honest score is that it closes the shutdown case, not signal handling, and the difference is deliberate rather than unfinished.
- CLOSED — a Tycho program can shut down cleanly on
SIGTERM/SIGINT.signal.on_shutdown(fd)(corelib/signal/signal.ty:14@on_shutdown) installs one handler for both signals whose only action isshutdown(fd, SHUT_RDWR)on the listening socket. One call arms an entire worker pool (server/main.ty@on_shutdown) because the handler is per-process and acts on the shared listener, so which thread the kernel delivers to never matters. No new control flow was needed anywhere: every thread blocked inacceptgetsErr, the wind-down arm that already existed (server/main.ty@accept_loop'sErrarm) retires each loop, and the count line prints. Measured at--workers 4, four connections held so all four loops were provably busy: exit status 0, the line,/proc/<pid>gone, shutdown in 1 ms. The gate now asserts the clean exit it used to assert the absence of, plusSIGINT, plusSIGKILLas the control — 52 assertions to 57. - STILL OPEN, narrowed — there is no general handler, and "narrow" is the design and not the backlog.
signal.on(sig, handler)was refused, with the reason written into the package header (corelib/signal/signal.ty; the previous pass cited , which theregister_conn/retire_connadditions pushed down): calling a Tycho function from handler context is a *language* feature, because every Tycho value lives in a bump-allocated arena that is not re-entrant and channel operations park behind a mutex (runtime/tycho_rt.c:1197@mu) — a handler that interrupts the allocator or the lock holder and then allocates or touches a channel deadlocks or corrupts the process it was invoked to shut down. What a general version would need, so the next person costs it rather than rediscovers it: (1) an async-signal-safe hand-off out of handler context — a self-pipe orsignalfdread by a dedicated thread — so the callback runs on an ordinary stack; (2) the handler itself still held to asig_atomic_tstore, unchanged from what ships; (3) apthread_sigmaskpolicy, sincepthread_createis called with a NULL attribute and there is no mask anywhere in the tree, so "an arbitrary worker runs your callback" is not a contract anyone can code against; (4) the re-entrancy clause in the spec. None of it is needed to shut a server down, and the tree has exactly one caller. - ~~NEW, measured — clean shutdown is not *prompt* shutdown, and
--idle-msis what bounds it.~~ CLOSED, the signals plan — and it took two phases because the entry named the wrong worst case. Phase 15 gavesignal.shutdown_requested()its caller (server/main.ty@shutdown_requested, in the keep-alive loop condition) and found a case the entry had not: a client keeping its connection *busy* held a worker for 102215 ms, servingMAX_REQSrequests at its own pace, because nothing told the loop to stop. That went to 6–8 ms. The entry's own case — a worker already parked in a blocking read — did not move at all, and phase 15 said so rather than claiming it: nothing wakes arecvon a *connection* fd when the handler shuts down the *listener*, so the loop condition cannot run untilSO_RCVTIMEOexpires. Phase 19 closed it by letting the handler reach accepted fds too: astatic volatile sig_atomic_t sigx_conns[256], one slot per worker, written only by that worker from ordinary context and only *read* in handler context, withfd + 1stored so static zero-initialisation is already correct — no lock, because the design removes the need for mutual exclusion rather than trying to take a mutex safely in a handler.signal.register_conn/signal.retire_connare the Tycho surface; the server calls them either side ofserve_conn(server/main.ty,server/main.ty). Measured--workers 4 --idle-ms 5000, before from a clean worktree ate35052b: 4 parked idle clients 4878 ms → 1 ms, 5 runs of 5, exit 0, the stopped line, andw1..w4all present in the access log.server/run.shgained the assertion that reddens on the old binary withwait status 137. The entry as it stood: A worker can only notice a shutdown between requests: one parked inserve_connon an idle keep-alive connection waits outSO_RCVTIMEOfirst. At--workers 4 --idle-ms 5000, 0 or 1 held connections shut down in 1 ms and four held connections take 5141 ms — exit 0 with the line in every case, so bounded and correct rather than hung. The 1-connection case is fast only by luck of routing: this kernel deliveredSIGTERMto the main thread, which is accept loop 1, which was the busy one, soEINTRreleased it directly. The fix already has its API and no caller —signal.shutdown_requested()(corelib/signal/signal.ty:18@shutdown_requested) exists for exactly this. Filed as the signals plan. - NEW, small — the mechanism that works is a Linux behaviour, not a POSIX guarantee.
shutdown()waking a thread blocked inaccept(2)on a *listening* socket is not specified, and backlogged connections are dropped rather than drained. It was chosen by measurement over three alternatives, not by preference:close(fd)released 1 loop of 4 and then handed the descriptor number back out to a lateropen()while three threads were still blocked on it, and apoll-before-acceptgate hung 2 of 4 under live traffic (a thundering herd past the readiness check). Recorded indocs/spec/18-library.md§32.27 as a property a second implementation must re-establish rather than assume. - What went right, and it is the same shape as the concurrency re-score above: the language and the build were not in the way. A new corelib package needs zero build wiring —
corelib/run.shglobscorelib/test/*/main.tyand picks the shim up by path, andtychocdiscoverscorelib/<pkg>/<pkg>_shim.cthe same way — so there is no registry to join, andmake -s corelibscored the new package on its first run. The whole surface is 65 lines of Tycho over 126 of C, and the only thing that cost real effort was the *measurement* that picked the mechanism.
Retired citation drift — three phases closed by decision, 2026-07-31
How this section and open list item 10 fit together, since they are about the same defect and used to read as if they disagreed. Item 10 is the *observation* — this file's coordinates rot and no gate can see it — and it is still true and still measured. This section and the one after it are the *decisions* taken about what to do with the population: correct-but-bare refs stay bare, unresolvable ones are retired, and only already-false refs are work. Item 10 was narrowed at the 2026-07-31 re-score to say exactly that, so the item no longer prescribes the sweep these sections decline.
Phases 21, 23 and 25 of the signals plan are stale path:line pointers, not wrong claims, and they are retired here rather than swept. Recorded so the next reader knows they exist and why nobody fixed them:
- 20 citations across four spec/guide files, shifted by the batch that added §22.1 and the
ncpu()correction. The shift bands are mechanical and were written down at the time (docs/reference/concurrency.mdold ≥105 → +13;docs/spec/13-concurrency.mdold 83..112 → +8, and so on). src/tychoc.c:4215points atgen_parfor98 lines short of where it is.- The package-mode comment above
dup_other_filecites two sites and both are wrong — one lands in array-copy codegen, the other in an enum comment.
Why retired. Every one is bounds-valid, so scripts/check_citations.py passes it; and every one will be re-staled by the next commit that inserts lines above it. This tree has now measured that twice: a repointing pass found 11 of 15 spot-checked citations had drifted again four days after the previous repair, and a later phase found a reference that had been repointed four times (§24.2 → §17.3 → §15.2 → E.2 rows), each fix setting up the next.
What actually closes this class, and it is not a sweep: the anchored path:N@token form, which reddens the gate when its target moves instead of rotting silently — and, since this was written, the path@SYMBOL form for a *definition*, which carries no line number at all and so cannot rot in the first place. The bare refs are bounds-checked and unverified; the anchored ones are checked. Run python3 scripts/check_citations.py --stats for the split; the numbers this paragraph originally quoted have been removed rather than updated, because CLAUDE.md says not to copy a figure the gate prints into prose and this paragraph was doing it in the middle of an argument about stale numbers. Converting the load-bearing ones is real work with a real payoff; repointing numbers by hand is a treadmill, and this file is where this repo records the difference.
Distinguish these from the two filed alongside them that were fixed rather than retired: docs/reference/ asserting an ncpu() behaviour the spec had just corrected, and a gate comment claiming one known-bad fixture where its own heredoc lists two. Those were false statements about behaviour. A stale line number misdirects; a false claim misinforms.
Retired citation phases, second set — 2026-07-31
Three more phases from the citation-gate work are retired here rather than done. Unlike the first set, two of these are not *expensive* — they are unresolvable as posed, and saying so is worth more than leaving them open to look thorough.
A line reference into the live plan names a document that no longer exists. plan.md is renumbered from line 1 every time a plan is archived, so 25 such refs — 11 already out of bounds — point at content that has moved to an archive under a different name. The number cannot be repaired: repairing it would mean guessing which archive, and it would go stale again next phase, because the live plan grows every phase. Bounds-checking plan.md would be a permanent unclearable red. This is the same rotation problem the plan-ref gate solves for *prose* references, and the line-numbered form has no equivalent fix.
The third spelling, and why the chase stops. A possessive form joining the filename to a phase number — 12 refs in four shapes — is invisible to a gate that now matches two word orders in three spellings each. One of its shapes names no file at all, so catching it means keying a pattern on a common English word and accepting false positives. The refs are genuinely stale; the *pattern* is the part that stops here. A fourth spelling exists and nobody has found it, which is the argument: matching English prose is not a mechanism that converges. What prevents new ones is the archiving discipline in CLAUDE.md — the commit that archives a plan rewrites the references it created — and that is enforcement at the point of creation rather than detection after the fact.
What was fixed instead, so the split is legible: phases 35, 38 and 39, all inconsistencies the same day's work introduced — a false-positive class created by widening the gate's reach, figures in CLAUDE.md left stale by a phase that updated only its own copy, and a genuine contradiction between the anchor check and the record-line rule. Cleaning up after yourself is different from chasing a population.
Re-scored against a batch, data-shaped program, 2026-07-31 (head ce0609e)
Everything above this line came from two programs of the same shape as each other: server/ is socket-shaped and tools/prunner/main.ty is concurrency-shaped, and both spend their time moving messages rather than transforming data. Neither one imports core:sha256 or core:compress — grep -n 'core:sha256\|core:compress' server/main.ty tools/prunner/main.ty returns nothing — and prunner never crosses bytes and string at all (zero hits for to_bytes, to_str and read_bytes). server/main.ty does cross it, seven times, but every one is to_bytes on a string literal or an error page on its way to a socket: it hands bytes along, it never hashes them, inflates them or parses a length field out of them. So every judgement in this file about hashing, about compression, and about what the byte/text split costs a caller who is *working* on the bytes was written by programs that did none of those things.
tools/tycho-ar/main.ty does. 854 lines: it walks a directory, hashes every file, gzips it, writes one archive, and reverses that byte for byte. It is gated by make ar-check (tools/tycho-ar/run.sh) on create-twice byte-identity, a t listing against a recorded golden, a diff -r round trip, and four kinds of refusal. Below is what writing it surfaced, ranked, worst first. The last group is the entries that got smaller as they were written down, and they are labelled as such rather than padded.
1. ~~The one-shot digest is what the language makes natural~~ — DOWNGRADED 2026-08-18: core:sha256 streams now
corelib/sha256/sha256.ty has init, update, final and final_hex, so the package this actually matters for — tycho-ar stakes file integrity on it — can hash something it does not hold entire. core:md5, core:hash and core:crypto remain one-shot, and that is the verdict rather than a backlog: md5 is kept for reading other people's formats, core:hash is a hash-table hash over a key already in hand, and AEAD is defined over a whole message. The language default the item describes is real; it now has an exception where the shape called for one. The record of what the item said when open follows.
The whole corelib hashes messages the caller already holds entire, and it is not an oversight. core:sha256 is digest(msg) and hex(msg), both over one string; corelib/sha256, corelib/md5, corelib/crypto and corelib/hash grepped together for sha256_(init|update|final), EVP_DigestUpdate and fn (init|update|final) return zero hits; and core:crypto's cx_sha256_hex is one-shot too, over a message the caller must hex-encode first. compress.compress (corelib/compress/compress.ty@compress) is bytes -> bytes with the same shape.
The reason is one compiler diagnostic. fn bump(a: [int]): a[0] = 1 is error: cannot mutate parameter 'a' (it is borrowed read-only; copy it with y := a first), and the suggested copy is a genuine copy — c := a; c[0] = 999 leaves a[0] alone, so a container parameter is a value, not a reference. A streaming state therefore cannot be threaded through calls by default. update is not a function you can casually write; it is a function you must first decide to spell inout. A one-shot digest(msg) needs that decision from nobody, so that is the interface that gets written.
inout is a complete answer and it works well — on [u32], on bytes, and it forwards (tycho-ar's own sha_feed handed its own &H to sha_block). Nothing here is broken. What is true is that the default steers the library, and a whole family of interfaces went one-shot because of it.
What it cost, measured. Hashing a file in bounded memory meant writing SHA-256: ~60 lines across tools/tycho-ar/main.ty — sha_feed, sha_finish, sha_bytes and sha_file, all four hand-rolled and none of them in that file any more (commit 2b226aa2 moved it to core:sha256's streaming API). Digests match sha256sum on 14 sizes straddling the 64-byte block, the padding overflow and the 64 KiB chunk. And the saving is real but partial: sha256.digest expands its message into buf := []int — one machine int per byte — so hashing an n-byte file allocated ~8n bytes of int array on top of the n bytes of file, and that term is now gone. But c still holds each file whole, because the compressor is one-shot in exactly the same way. Peak memory for c is still O(file size); chunking removed the 8x multiplier on top of the file, not the file.
Cost to fix: an inout-threaded init/update/final beside digest in core:sha256 is ~80 lines of Tycho and no language change — the block loop and the padding already exist in this tree, written twice now. Bounding the last term needs a streaming deflate, which is a core:compress interface plus a shim change and is the larger job. This ranks first because it is not a missing function; it is a missing habit, and it will reproduce in the next package anyone writes.
2. ~~The wrong string compiles silently, and there is no wrong-looking output~~ — the mechanism did not reproduce, 2026-08-10
The predicted friction did not materialise, and the real one is worse. This file's existing entry on the split says a string is fully byte-safe and that the bytes/string boundary buys "type-level intent and FFI shape, not binary safety"; the plan predicted that crossing it per file would be the seam this program hit first. It was not. The crossing is sha256.hex(to_str(raw)) — one call, no copy, to_str a reinterpret of the same length-headered buffer (docs/spec/06-conversions.md) — and eight fixture digests including a file with interior NULs match sha256sum byte for byte. Half of a prediction in this file was wrong again, and it is the half that sounded expensive.
What is expensive is that nothing at the type level distinguishes "this string is text" from "this string is raw bytes". io.read and io.read_bytes are one keystroke apart, both plausible at the call site, and reaching for the wrong one gives a digest over a NUL-truncated prefix: no diagnostic, no exception, and an output that looks exactly like a digest. In an archiver that is silent corruption of the field whose entire job is detecting corruption.
THE HAZARD ABOVE DOES NOT REPRODUCE — re-probed 2026-08-10, and this entry was wrong about its own mechanism. A 5-byte file AB\0CD read three ways:
| path | len | digest |
|---|---|---|
io.read (text) | 5 | matches sha256sum |
io.read_bytes -> to_str | 5 | matches sha256sum |
io.read does not truncate at an interior NUL, so "reaching for the wrong one gives a digest over a NUL-truncated prefix" is not a thing that happens. The entry contradicts itself: two paragraphs earlier it says a string is fully byte-safe and length-headered, which rules out exactly the truncation it then asserts.
The proposed fix is therefore withdrawn, not built: sha256.hex_bytes and base64.encode_bytes would have added surface against a hazard that is not there.
What the probe DID find, which this entry missed. The two readers are not "one keystroke apart, both plausible" — they have different SHAPES. io.read(p) -> string has no error channel at all, while io.read_bytes(p) -> Result(bytes, IoErr) does. You cannot silently swap them; the compiler stops you. What io.read actually hides is I/O failure — a missing or unreadable file — and that is a real asymmetry worth its own entry, about error reporting rather than about bytes.
3. ~~compress.decompress cannot distinguish empty from corrupt~~ — CLOSED 2026-08-10
Pinned-by: test -f corelib/test/compress/main.ty Pinned-by: make corelib
decompress and raw_decompress return Result(bytes, ZErr), with Corrupt, Truncated and Failed. The entry was right that the information was already there and being discarded: every failure branch in the shim set *outlen = 0 and returned, so the branch that knew threw the knowledge away. It now sets a status through an inout int out-param — the same FFI shape core:io's iox_read_file already used, since a bytes return cannot also carry a code.
The three answers are now distinct, and the corelib golden is the record:
empty=Ok len=0 <- a real zero-byte payload
corrupt=Corrupt len=-1
truncated=Truncated len=-1
tools/tycho-ar reports the cause directly instead of inferring damage from a length mismatch, and its run.sh leg 4c now expects payload is corrupt. The stored-length check STAYS: a payload that inflates cleanly to the wrong thing is forgery rather than damage, and no inflate status can catch that.
One nuance found while testing, and documented at raw_decompress: raw DEFLATE has no wrapper and no checksum, so junk is a valid-looking bit stream that runs out, and zlib reports it as no-progress — which decodes to Truncated. On a RAW stream, Truncated means "not a deflate stream" as often as it means "the rest exists somewhere". The gzip/zlib entry point has a header and a checksum and tells the two apart.
The original text follows.
3. ~~compress.decompress cannot distinguish empty from corrupt~~ — CLOSED 2026-08-18, by probe
Pinned-by: test -f corelib/compress/compress_shim.c Pinned-by: make corelib
Re-probed today and it distinguishes them: a round-tripped empty payload gives Ok with length 0, a payload cut in half gives Err, and zero bytes in gives Err. Empty is an answer and corrupt is an error. The record of what the item said when open follows.
Measured, not reasoned. A probe compressing "", a 65-byte payload with byte 12 XORed by 0xFF, and that payload cut in half:
legit-empty: gzip len=20 inflated len=0
legit-empty: sha=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
good : gzip len=65 inflated len=51
corrupt : gzip len=65 inflated len=0
corrupt : sha=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
truncated : inflated len=0
truncated : sha=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Legitimate-empty, corrupt and truncated are byte-identical answers. The return value carries no discriminator at all, and the shim is why: every error path in corelib/compress/compress_shim.c@zx_decompress sets *outlen = 0 and returns.
An archive can legitimately contain a zero-byte file, so for any container format this means a corrupt member reads as an empty one — data loss that looks like data. tycho-ar survives it only because the format carries the original length out of band and checks it (tools/tycho-ar/main.ty@cmd_x: inflated to 0 bytes, header says 12 (corrupt payload)), and tools/tycho-ar/run.sh leg 4c forges the payload digest specifically to prove that check, not the digest, is what catches it. A caller who trusts the return value has no such recourse.
Cost to fix: small, and the information is already there. zx_decompress knows which branch it took; it discards that on the way out. Returning Result(bytes, string) — the shape every other fallible corelib call now uses — is a shim return value, a signature and the call sites. This ranks third only because the workaround exists; for a caller without an out-of-band length there is no workaround at all.
4. ~~strings.parse_int fails open, so no format parser can use it~~ — ALREADY FIXED; the last duplicate is gone 2026-08-10
Pinned-by: test -f corelib/test/strings/main.ty Pinned-by: make corelib
strings.parse_int_checked(s) -> Result(int, IntErr) exists and has since before this was re-read — EmptyInput, Garbage, OutOfRange, with ten cases in corelib/test/strings. The entry asked for parse_int_strict; the same thing shipped under a different name and the entry was never struck. parse_int is untouched, as the entry wanted.
What remained was the entry's other half: the hand-rolled copies. server/main.ty had already been converted. tools/tycho-ar/main.ty@parse_uint was the last one, and it is now a thin wrapper — not a straight swap, because a straight swap would have WEAKENED it: parse_int_checked accepts a leading - and the whole int64 range, and an archive length field is non-negative and must stay far below int64 so a forged length cannot drive a huge allocation. The split is the one parser-safety asks for: the LEXICAL rule is shared, the DOMAIN rule stays with the format that owns it.
Checked at the boundaries, including the entry's own case — "1x4" is -1, not 1 — plus empty, leading junk, trailing space, negative, at-cap, over-cap and int64 overflow. A gap: in the source records that the lane forges a damaged header but not a negative or over-cap one.
The original text follows.
~~4. strings.parse_int fails open, so no format parser can use it~~ — the strict counterpart EXISTS and the ask is paid; one caller was still fail-open and is fixed. Verdict 2026-08-14
Pinned-by: test -f corelib/strings/strings.ty Pinned-by: make corelib
corelib/strings/strings.ty@parse_int returns 0 for "", 0 for a leading non-digit, and stops at the first non-digit without objecting — a damaged length field of "1x4" parses as 1. That is the right behaviour for user input, where 0 is a fine default, and the wrong one for a length field, where a wrong length that *parses* is precisely how a reader ends up hashing the wrong span of bytes. tools/tycho-ar/main.ty@parse_uint is the strict version this program had to carry: returns -1, no silent prefix.
The entry's ask is stale. corelib/strings/strings.ty@parse_int returns Result(int, IntErr) with EmptyInput / Garbage / OutOfRange, and six real programs already call it. Measured 2026-08-14 over 14 inputs, lax against strict:
[] lax=0 strict=Err(EmptyInput) [3x] lax=3 strict=Err(Garbage)
[-] lax=0 strict=Err(Garbage) [1x4] lax=1 strict=Err(Garbage)
[ 7] lax=0 strict=Err(Garbage) [7 ] lax=7 strict=Err(Garbage)
[+3] lax=0 strict=Err(Garbage) [007] lax=7 strict=Ok(7)
[9223372036854775808] lax=0 strict=Err(OutOfRange)
[-9223372036854775808] lax=-9223372036854775808 strict=Ok(-9223372036854775808)
parse_int is unchanged and still lax, deliberately — 0 is the right answer for a CLI option. What the entry got right is that a *format* parser must not use it, and one still did: tools/tycho-make/build/build.ty@stamps_read validated the tab structure, the name and the 64-char hash, then read the mtime with the lax call. A stamp of x<TAB>17x<TAB><hash> was accepted as mtime 17, and the mtime decides staleness — so a corrupted stamp file shipped a stale output as current. With the fix reverted the new lane leg reports EXITED 0 -- a damaged mtime was accepted, i.e. the build succeeded off the corrupt stamp. tools/tycho-make/run.sh leg [13b] pins it; the pre-existing StampBroken leg could not, because it writes garbage, which has no tab and dies at the first validation.
~~5. bytes slices clamp, so a slice is not a bounds check~~ — REPRODUCES, and the clamp is DELIBERATE; the entry's own residual ask was already paid. Verdict recorded 2026-08-13
Re-probed 2026-08-13 against the reference languages the entry never consulted. Every factual claim below still holds — this closure is a verdict, not a correction. What changed is that the behaviour is now *decided* rather than merely observed, and the entry's closing sentence ("the one-paragraph spec warning is still unwritten") is stale: it was written in commit a335b8db on 2026-08-11, hours after the note that says it is missing.
The probe, four out-of-range shapes on a 5-byte value, all bounds routed through a fn at(n: int) -> int so nothing folds at compile time:
b past=3 far=0 <- b[2:10] is 3 bytes; b[7:9] is 0
b neg =2 inv=0 <- b[-3:2] clamps to 0; b[4:1] inverts to empty
s past=3 far=0 <- the identical string slice, identical clamp
s neg =2 inv=0
bytes and string are the same on all four — that half of the entry is confirmed, not a divergence between them. An array aborts on all four, and the check is purely a runtime one: a[2:10] on a 5-element array compiles cleanly (exit 0 from tychoc) and dies only when run, so there is no compile-time arm to strengthen. The array check is emitted inline into the generated C by the compiler — src/tychoc.c:15040-15043 for an ordinary array (the path the probe above took) and the same test again at src/tychoc.c:15018-15020 for the SoA variant, both spelling it _lo < 0 || _hi > len || _lo > _hi, which is why all four shapes abort and not just the two that overrun. The clamp is runtime/tycho_rt.c@tycho_str_substr, whose three lines are exactly start<0 -> 0, end>n -> n, end<start -> start.
Both reference languages fault where Tycho clamps, and that divergence is the finding. Measured here, not recalled — go1.26.5 panics on all six shapes tried, including the string slice:
[]byte[2:10] PANIC slice bounds out of range [:10] with capacity 5
[]byte[4:1] PANIC slice bounds out of range [4:1]
string[2:10] PANIC slice bounds out of range [:10] with length 5
and Odin (dev-2026-04-nightly) traps by default, naming the source line — m.odin(10:8) Invalid slice indices 2:10 is out of range 0..<5. Note that -no-bounds-check, documented as disabling bounds checking "program wide", does not lift this one: the slice-index check survives it, so Odin's slice bound is stronger than its own opt-out suggests.
Verdict: keep the clamp. The divergence is real but it is not the same choice being made differently — a Tycho b[i:j] *is* substr, the function form, which clamps by definition and is right for the text processing it exists for. Go has no clamping substring operator to keep consistent, so its panic costs it nothing; changing Tycho's operator would either desynchronise it from substr or drag substr along with it. Against that, the operator is not the only spelling available, and the fail-closed one already exists.
What a caller wanting a real bounds check should use: corelib/strings/strings.ty@slice_bytes and @slice_str, which take the same (start, stop) the operator does and return Result(_, SliceErr). Verified running, same four shapes: OutOfBounds, Inverted, OutOfBounds, then ok len=3 for the in-range case.
What this closure adds beyond the verdict: a gate. The clamp was pinned by nothing — tests/string_slice.ty covers only in-range slices, so a change making Tycho match Go and Odin would have passed make test green. A deliberate divergence from both reference languages with no fixture watching it is the exact shape of a behaviour that gets "fixed" by accident. tests/slice_clamp.ty now locks all eight cells above; it was proved able to fail by replacing the three clamp lines with a Go-style abort, which reddens it (FAIL slice_clamp (native exit 1)) before the runtime was restored.
The original entry follows.
5. ~~bytes slices clamp, so a slice is not a bounds check~~ — CLOSED 2026-08-18: deliberate, and PINNED
Pinned-by: test -f tests/bytes_slice_clamp.ty Pinned-by: make test
The clamp is the documented rule (docs/spec/03-types.md, the b[i:j] row: it clamps exactly as a string slice does) and it is not changing. What was missing is that nothing asserted it, so a change to trapping could have landed in silence. tests/bytes_slice_clamp.ty pins all three cases now. The hazard is real and the answer is a rule, not a fix: compare len first, never use a slice as a bounds check. The record of what the item said when open follows.
data[p:p + 8] past the end of a bytes value yields three bytes rather than trapping (docs/spec/03-types.md:139). For a format parser that is a trap dressed as a convenience: a reader that treats the slice as its bounds check reads a short footer, compares it against the real one, and gets the right answer by accident — then slices a payload, hashes a prefix, and gets the wrong one. Every read in tools/tycho-ar/main.ty@parse therefore tests len(...) of what came back against what it asked for.
The hazard is the spelling, not the semantics. An array slice aborts; a string slice and a bytes slice clamp; all three are written x[a:b]. Clamping is right for a text tool and the identical syntax is what makes it invisible. Cost to fix: documentation, and it is already partly paid — the behaviour is specified. What is missing is the warning beside it, one paragraph in docs/spec/03-types.md saying in words that a clamping slice cannot be used as a bounds check.
Re-probed 2026-08-11: every claim above reproduces. A five-byte bytes, one program, one run:
len(b)=5
b[2:10] len=3 <- three bytes, no diagnostic
b[7:9] len=0 <- entirely past the end, no diagnostic
b[-3:2] len=2 <- negative start clamps to 0
t[2:10] len=3 <- the identical string slice, identical clamp
t[7:9] len=0
and the array half of the "hazard is the spelling" claim, same shapes, same syntax, different outcome — a := [1,2,3,4,5]; a[2:10] dies:
tycho: slice [2:10] out of bounds (len 5)
exit=1
Answered 2026-08-11, in corelib rather than in prose. corelib/strings/strings.ty@slice_bytes and @slice_str take the same (start, stop) the language does — strings.slice_bytes(d, p, p + 8) reads like d[p:p + 8] — and return Result(_, SliceErr): OutOfBounds when start < 0 or stop > len, Inverted when start > stop. Clamping stays the default and no existing caller moves; this is the opt-in a format parser wants, so the len(...)-of-what-came-back test tools/tycho-ar/main.ty@parse hand-rolls at every read has a name now. Locked by ten lines in corelib/test/strings.out (sb past=OutOfBounds is the b[2:10] case above). ~~The one-paragraph spec warning is still unwritten and still worth writing.~~ It is written: read 2026-08-13, docs/spec/03-types.md:144-154 carries "A bytes slice is not a bounds check" — the silent clamp with both worked examples, the contrast with an array slice that ABORTS, the MUST NOT infer-from-return rule, and strings.slice_bytes named as the checked form. Nothing left to write.
~~6. There is no eprintln, and the missing channel removed a feature~~ — the channel was never missing; eprint has shipped since 2026-06-14, 2026-08-11
The entry's load-bearing sentence — "the builtins are println, die (stderr, then exit 1) and exit(n)", so "a non-fatal warning is inexpressible" — is false, and was false when it was written. eprint(s) is a builtin: registered at src/tychoc.c:4929@eprint, emitted as tycho_eprint, and defined as fputs(s, stderr) in runtime/tycho_rt.c@tycho_eprint. It is specified — docs/spec/16-builtins.md:70@eprint says "Write s's bytes to stderr; no newline, no exit" — and it was added on 2026-06-14 in 61fa0dc ("+ eprint primitive"), i.e. before this file existed. Nine non-frozen .ty files in the tree already call it, corelib/log/log.ty among them.
The probe that decided this, a t-shaped listing that complains about one member on stderr, keeps listing, and exits 0:
$ cat warn.ty
fn main():
names := ["a.txt", "BAD.bin", "c.txt"]
for i := 0; i < len(names); i += 1:
if names[i] == "BAD.bin":
eprint("tycho-ar: " + names[i] + ": payload digest mismatch, skipped\n")
continue
println(names[i])
$ ./warn 2>/dev/null # stdout: data only, no diagnostic
a.txt
c.txt
$ ./warn 2>&1 >/dev/null # stderr: the warning, on its own channel
tycho-ar: BAD.bin: payload digest mismatch, skipped
$ ./warn >/dev/null 2>/dev/null; echo $?
0
So the "removed a feature" half does not stand: t's all-or-nothing interface was a choice, not a consequence. Nothing prevented a per-member note on stderr beside a clean listing on stdout. The interface is still defensible on its own merits — one verification pass before any output is a stronger promise than tar t's partial listing — but it must be argued as a decision. It now is: the header comment at tools/tycho-ar/main.ty:218-232 was rewritten on 2026-08-11 to drop the false premise and state the decision.
What was true until 2026-10-19 was only the heading's first four words. There was no eprintln, confirmed: eprintln("x") gave error: unknown procedure 'eprintln'; did you mean 'println'? and exit 1. The whole residue was that a warning cost a + "\n" the tree wrote in nine places. That is sugar, not a missing channel, and it did not rank on this list.
Status, 2026-10-19:
eprintln(s)is now a builtin in both compilers (Phase 5 of thetycho-dupefriction phases); the residue is closed and the old refusal fixture is gone. Pinned-by: grep -q '"eprintln"' surface.lock
~~7. gzip byte-determinism is real, undocumented, and load-bearing~~ — entry was right; the guarantee is now written down, 2026-08-11
Pinned-by: test -f corelib/compress/compress_shim.c Pinned-by: make corelib
Every archive this program writes is reproducible because zlib writes zero into RFC 1952's MTIME header field unless the caller supplies a gz_header, and corelib/compress/compress_shim.c@zx_compress supplies none. A compressor that filled that field in — as the gzip(1) command does by default — would make every archive differ from the last, and make ar-check's first leg would redden on a program that had done nothing wrong.
Nothing in docs/spec/18-library.md's core:compress entry says the output is byte-deterministic. So the tree's one reproducibility gate rests on a property that is true, verified by reading the shim, and promised by nobody — a second implementation of core:compress could set MTIME and still conform. Cost to fix: one sentence in the spec, which converts an accident into a contract. Cheapest item on this list by a wide margin, and the only one where the fix is purely making an existing truth binding.
Answered 2026-08-11. The claim reproduced in every part, and the guarantee is now stated in three places a caller actually reads: the spec entry docs/spec/18-library.md §33.3 (binding on any implementation, so a second one that filled MTIME no longer conforms), the package header corelib/compress/compress.ty, and docs/reference/corelib.md's compress bullet.
The probe. A program that gzips a fixed 2,400-byte payload and writes the result to a file, run twice three seconds apart in different time zones:
$ ./gzdet p1.gz # 09:05:43 UTC, TZ=UTC
wrote 82 bytes to .../p1.gz
$ ./gzdet p2.gz # 09:05:46 UTC, TZ=Pacific/Auckland
wrote 82 bytes to .../p2.gz
$ cmp p1.gz p2.gz && echo identical
identical
$ sha256sum p1.gz p2.gz
f6429240f0406b23e1c1bc472208144cf15d8ed5463d1a62c52f313ec11895a1 p1.gz
f6429240f0406b23e1c1bc472208144cf15d8ed5463d1a62c52f313ec11895a1 p2.gz
$ od -An -tx1 -N10 p1.gz # MTIME is bytes 4..7
1f 8b 08 00 00 00 00 00 00 03
MTIME is four zero bytes, exactly as reading the shim predicted. The negative control that proves the cmp can fail: gzip -c on the identical payload, on this host, gives 1f 8b 08 00 f1 e5 7a 6a 00 03 — a filled MTIME — and cmp p1.gz g1.gz reports differ: byte 5, i.e. the first byte of the MTIME field and nothing before it. (One correction to the entry's parenthetical: gzip 1.13 here fills MTIME from its *input file's* mtime, not from the clock, so two gzip runs of the same file agree with each other. Either way it is non-zero and input-dependent, which is what would break reproducibility.)
What depends on it, enumerated rather than assumed. Grepping the tree for core:compress gives twelve files; the ones that are gates are:
tools/tycho-ar/run.sh— leg [1] create twice, byte-identical, acmp -sof two archives built from one tree. This is the only gate in the tree that would redden, andmake ar-checkis the only lane that runs it.corelib/test/compress/main.ty→corelib/test/compress.out— asserts only boolean invariants (roundtrip=1,smaller=1,empty=Ok len=0, …) and no compressed bytes or lengths, deliberately, per its own header comment. It does not depend on determinism and would not catch a loss of it.corelib/zip/zip.ty→corelib/test/zip.out— locksmethod=andusz=(uncompressed size), nevercsize. Also independent of it.tools/tycho-ar/expected.out— thetlisting golden holds each member's uncompressed size and the sha256 of the original payload, so a change in zlib's tuning does not move it. Only leg [1] is exposed.
So the entry's "load-bearing" was accurate but narrow: one gate, one leg, and nothing else in the tree can see the property at all. That is the argument for writing it down rather than relying on a reviewer to notice.
8. ~~mtime is captured faithfully and cannot be restored~~ — CLOSED 2026-08-11 in core:io
Pinned-by: test -f corelib/io/io_shim.c Pinned-by: make corelib
Re-probed and reproduced exactly before the fix. io.mtime read one; io.set_mtime did not exist — tychoc on a probe calling it said error: package 'io' has no symbol 'set_mtime', and utime/utimensat did not appear anywhere in corelib/io/. The archive format was never the gap: every member header does carry a real st_mtime (tools/tycho-ar/main.ty:36), so this was data captured correctly that the extractor had no call to apply.
Closed by the write side of the same stat field: corelib/io/io_shim.c@iox_set_mtime plus corelib/io/io.ty@set_mtime, which returns Result(bool, IoErr) — the shape write_bytes and make_dir already use. utimensat with UTIME_OMIT on the access time, so restoring a modification time does not stamp an access time the caller never mentioned; Windows has no utimensat, so there the atime is read back and passed through. A directory is Ok, matching io.mtime's read side.
Fixture: corelib/test/io/main.ty, four assertions in make corelib — an exact epoch the run did not produce (exact), Err(NotFound) on a path that is not there (set_missing), that the failing call created nothing (made_nothing), and the directory round trip (dir_back). Three of the four flip when the shim is stubbed to a no-op, which is what makes them a test.
The caller followed the same day. tycho-ar's x now calls it (tools/tycho-ar/main.ty), fatally rather than with a warning, matching every other partial failure in that program. The gate needed the same work: diff -r does not compare mtimes, so the round-trip leg was green over the hole and would have stayed green over a broken restore. tools/tycho-ar/run.sh leg 3b compares the times themselves, and was confirmed to fail — naming all eight members — with the set_mtime call stubbed out.
Smaller than they looked once written down
Recorded because they were hit, ranked below the line because writing them out shrank them. Padding this list would make the eight above harder to act on.
- ~~No
io.write_bytes.~~ — CLOSED by commit 2b226aa2; struck 2026-08-21.corelib/io/io.ty@write_bytestakesbytesand returnsResult(void, IoErr), classified asread_bytesis, andwrite_atlanded beside it. The record of what the bullet said when open follows. Writing bytes isio.write(p, to_str(b)), which is correct —runtime/tycho_rt.c@tycho_write_filefwrites the length header to a"wb"handle — but a caller has to read the runtime to know that, because the signature saysstring. Smaller than it looked: it is one signature away from symmetric withio.read_bytesand it never actually cost this program a bug, only a paragraph of comment justifying a line. Legibility, not safety. - ~~No
mkdir -p.~~ — CLOSED 2026-08-21:corelib/io/io.ty@make_dir_all. Pure Tycho overmake_dir, no shim;tools/tycho-ar/main.ty@mkdir_pis the wrapper that names the error and is now 6 lines. The entry was half wrong and the correction is the interesting part:docs/reference/corelib.mdalready refused this in writing — *"one entry, never recursive, nomkdir -pand norm -rfbehind a corelib name"* — so the bullet was asking for something the tree had decided against, and neither document knew about the other. The rule was narrowed rather than dropped: what it protects is that no name is SECRETLY recursive,_allis the disclosure, and there is still deliberately noremove_all. The record of what the bullet said when open follows.corelib/io/io_shim.c@iox_make_diris onemkdir(2), which is the correct primitive, andOk(false)for "already a directory" is exactly the right interface — it is what makes the loop idempotent. Every caller writing into a tree it does not own rebuilds the component chain;tools/tycho-ar/main.ty@mkdir_pis 18 lines of it. Real, and 18 lines. Re-scored 2026-08-21 — reproduces, and the number was wrong in the cheap direction:@mkdir_pis 39 lines, not 18. "Every caller" was also too strong — it is the tree's ONLY recursive one;tools/tycho-fetchandtools/prunnercallio.make_dironce, for a parent they already own. So the cost is 39 lines in one place rather than a little in many, which makes it a smaller argument for a corelib entry point than the bullet implies, not a larger one. - A package cannot mark a top-level function internal. Every
fnincorelib/sha256/sha256.tyis callable assha256.<name>from an importing program —k_table,h_init,ch,maj,pow2,hex2all probed and returned. Both halves are true and they cancel: this is what made item 1's 60 lines cheap, since not one constant or round table is duplicated; and it means every corelib helper is public API by accident, so any rename is a breaking change to callers the author never knew existed. No cost estimate, because the fix is a visibility rule and that is a language question, not a corelib one. core:iois path-based, with no file handles. The compressor's read and the digest's reads are separateopen(2)s over the same path, so an archiver cannot read a file atomically. A writer racing between them is detectable — a read returning zero before the expected length was fatal in tycho-ar'ssha_file— but not preventable. Closed as written, 2026-08-21: the archiver stopped re-reading.member_ofhashes the buffer it already read and compressed (tools/tycho-ar/main.ty@member_of), so there is oneopen(2)per file and no window for a writer to race into. Unlike the two items above this is the shape of the whole package rather than one missing call, which is why it is an entry here and not a proposal.- No expression line continuation.
x := a + b +followed by a continuation line iserror: expected an expression, caret at the column after the trailing+. Every header build in this program has that shape, so one two-line expression became three statements. The only thing that changed the code rather than the comments, and it changed it by two lines. chr(n)is the only route from a number to a byte. There is nobytesbuilder from integers;to_bytestakes astring. SHA-256's padding is therefore assembled as astringand converted. Harmless at ≤120 bytes built once per file, and it would not be in a hot loop. Pairs withio.write_bytes:bytesis a good type to receive and an awkward one to construct.- A match arm cannot be empty. An arm with no body is
error: expected an indented block, with the caret on the *next* arm's line, so a success case with no work still needs a statement (Ok(_): continue). Kept only because it is the same family as the line-continuation entry — the grammar has no way to say nothing. And it is a correction: the first draft of this claimed there was no_wildcard binding, which is false —Ok(_)compiles and a whole-arm_:wildcard is atdocs/spec/12-aggregates.md:813. An unverified absence claim nearly shipped into this file, which is the one file here where that is expensive. pushhas no inverse. Nopop, so the directory walk is a queue with a cursor rather than a stack. Not a defect — the queue reads better — but the data structure was chosen by the corelib rather than by the program.
What did not go wrong, which is also data
- It compiled first try, three phases out of three. The list/extract phase and the chunked-hashing phase each built with no errors on the first
./tychoc, including a hand-written SHA-256 withinoutstate threading through four functions. This matches what the concurrent-program re-scoring above found and contradicts the picture the older half of this file paints. - The
bytes/stringcrossing cost one call and no copy (item 2). sort.ascover[string]is unsigned-byte lexicographic and locale-free, because string comparison ismemcmpover the length header (runtime/tycho_rt.c@tycho_str_cmp). That is what a format needs, and a locale-aware comparison would have quietly broken reproducibility across hosts.runtime/tycho_rt.c@tycho_list_dirreturns filesystem order, so the sort is the entire determinism story on the walk side.path.safe_joinfails closed and was asserted, not assumed. A hand-built archive whose member path is../a.txt— the same eight bytes asxx/a.txt, and no digest in the format covers the path, so the substitution leaves a structurally valid archive — is refused before the first write, destination never created. That is leg 4a oftools/tycho-ar/run.sh, and neutering the substitution reddens it, so the leg is measured rather than assumed.
Re-scored against a type-system-shaped program, 2026-08-01 (head 721e646)
The section above re-scored this file against a batch, data-shaped program and found one language default steering a whole family of library interfaces. But tools/tycho-ar/main.ty is structurally flat: one struct, no enum of its own, no closure, and no recursion except a directory walk. So the half of the language the documentation is loudest about — recursive sum types, generics with where constraints, first-class function values — had still never been exercised by anything in this tree but corelib/json/json.ty and the test corpus. Every judgement in this file about what it costs to *model* something was written by programs that modelled nothing.
tools/tycho-q/main.ty does. 2059 lines: a lexer, a recursive-descent parser producing a recursive Expr enum, a Value sum type that every row cell and every intermediate result flows through, an evaluator that is an exhaustive match over the AST, a stable multi-key merge sort taking a comparator as a first-class value, and two readers. It runs select name, qty * price as total from sales.csv where region == 'eu' and qty > 10 order by total desc limit 5 and returns the right rows. It is gated by make q-check (tools/tycho-q/run.sh) on a 31-query transcript, select * being byte-identical to its input, CSV and JSON agreeing under cmp, and ten failure legs refusing with empty stdout.
Below is what writing it surfaced, ranked, worst first. The two corelib defects at the top were filed as unchecked phases in the plan that produced this section and were deliberately not fixed here — they are corelib changes and this was a tools phase. Finding 1 has since been fixed, in two plans; finding 2 is still open. *(Both are closed as of 2026-08-13: finding 2 is the core:decimal div, fixed 2026-08-02 by commit a8c761c and banner-corrected 2026-08-11. This sentence is left as written, with the correction beside it, for the same reason the finding texts are: it is the record of what was believed.)* Each carries its own status banner below, and the finding texts are left exactly as they were written: they are the record of what was wrong, and a repaired description of a fixed defect describes nothing. The last group is the entries that got smaller as they were written down, and they are labelled as such rather than padded.
1. ~~core:json accepts input it cannot represent, three ways, and cannot report any of them~~ — FIXED 2026-08-01, re-probed 2026-08-11; see the banner below
Pinned-by: test -f corelib/json/json.ty Pinned-by: make corelib
[FIXED, 2026-08-01.] Both halves are closed, by two plans in sequence. The finding below is left verbatim, including the parts that are no longer true of the tree —
json_guardis gone,parseis no longer the only entry point, and none of the three probe lines reproduces. It stays because it is the only record of what the package did and whytycho-qis shaped the way it is.The error channel — which the finding names as the root cause, correctly — was closed by the json-error plan, whose phase 1 landed as commit
84bcfa2:corelib/json/json.ty@parse_checkedreturnsResult(Json, JsonErr), every variant carries the byte offset of the byte that failed, and the non-termination is unreachable rather than merely caught (corelib/json/json.ty@parse_valueno longer falls through to a number parse for a byte that begins no value, and both container loops carry a cursor-must-advance guard). That is what lettools/tycho-q/main.ty— most of a second JSON parser, written only because the first one could not speak — be deleted rather than maintained.The float path, which the finding correctly separates as "a separate, larger question", was closed by the plan this banner was written under:
b260333addedcorelib/json/json.ty@JFloat, carrying the binary64 value and the original lexeme, so a number the parser cannot represent exactly keeps its digits andstringifyre-emits them byte-for-byte; the same commit ended the silent 64-bit integer wrap, which was the last silent-wrong-value path in the package.0f74a7badded\uXXXXand surrogate-pair decoding, and42047a1made the grammar exactly RFC 8259's — trailing commas, leading zeros, trailing text after the document and raw control bytes inside a string are all refused now, each naming the byte the writer has to change.And the interaction the finding predicted with item 2 resolved the other way. It expected the float path to be blocked on
core:decimal, since "core:decimalis the only exact numeric tower here andJNumis anint". It was not:core:jsonstores the lexeme, sotycho-qmaps it straight throughdecimal.from_str(tools/tycho-q/main.ty@json_float_cell) and never touches the binary64 value. A JSON1.50and a CSV1.50are now the sameVDec— they compare equal, sort together, multiply exactly and render identically, whichmake q-checkasserts withcmpover the same query run against both fixtures. Two spellings are refused rather than rounded (1e3,-0.0), for the stated reason that the same text is already refused in a query literal and in a CSV cell. Finding 2 is untouched by any of this and remains open: there is still nodecimal.div. *(That last clause was true when it was written and stopped being true the next day —decimal.divlanded 2026-08-02 in commita8c761c, and finding 2 is closed. Left in place because the sentence it qualifies — that nothing in thecore:jsonwork depended on division — is still the point.)*Re-probed 2026-08-11 along the axis the title claims — "input it cannot represent" — and the round trip is faithful on all five. Measured, not inferred: an integer stays
JNumand re-emits as itself, and every number the lexeme covers (1.0,9223372036854775808, a 30-digit integer) comes back byte-identical; object keys keep insertion order; duplicate keys are ALL kept, so{"a":1,"a":2}round-trips unchanged; a\uXXXXescape decodes to UTF-8 bytes and is a fixed point rather than an identity, with an embedded NUL surviving as61 00 62and re-emitting as the escape\u0000; andnull, absent and the empty string are three distinct states in the tree. Nothing was fixed, because nothing was broken. What the probe did find was two decisions that were real and written down nowhere: duplicates are kept withgetanswering the FIRST, andgetreturns JNull for both a null member and an absent one, so membership is akeyswalk. Both are now in the corelib header under OBJECTS, both are asserted bycorelib/test/json/main.ty, and the stalecore:jsonbullet indocs/reference/corelib.md— which still said "integers (no floats)" and never mentionedparse_checked— was corrected against the source.Re-probed again 2026-08-13 — all three sightings, SEPARATELY, and none of them reproduces. One program per sighting in its own
mktemp -d, each calling the lenientparsethe finding used as well asparse_checked:1.5isfloatand re-emits as1.5;[1.5]returns[1.5]and exits 0 underulimit -v 1000000, so the unbounded loop is gone rather than merely faster;[{"a":1.5}]re-emits itself and invents no key. All three were already pinned bycorelib/test/json/main.ty:126-135, each with the original symptom in a comment beside it, so no test was added — a fourth would have been a copy.What that probe DID add is the reference-language verdict, which nothing here had. The same twelve inputs went through Go's
encoding/jsonand Odin'score:encoding/json(forced to.JSON; Odin's default isJSON5, which accepts more still). Tycho refuses, naming a byte, in every case where either of them corrupts in silence: Go rounds a 30-digit integer to1.2345678901234568e+29and Odin WRAPS it to-4362896299872285998; both read1e-400as0, and Odin reads1e400asInf; Go swaps a raw0xFFfor U+FFFD and Odin ABORTS on it (an assertion inside its own parser); and Odin under.JSONstill accepts trailing text, a leading zero and a trailing comma, and silently TRUNCATES"a<TAB>b"to"a".Duplicate keys is the one axis that matches neither reference, and it stays. Go keeps the last and drops the first; Odin refuses the whole document (
Duplicate_Object_Key). Tycho keeps both withgetanswering the first — the only one of the three that is lossless, and the only one where a caller can still choose either reference behaviour afterwards, by readingkeys. Deliberate, not changed: both alternatives destroy something the document contained.
This is the most serious thing this program found, and it has no symptom. Measured by probe, not inferred. corelib/json/json.ty@parse_number takes an optional - and then digits, and never consumes a .:
$ ./pj '1.5' → kind:num out: 1 exit 0
$ ./pj '[1.5]' → tycho: out of memory exit 1
$ ./pj '[{"a":1.5}]' → kind:arr out: [{"a":1,"5}]":null}] exit 0
Three different failures, and two of them exit 0:
- Bare
1.5truncates silently toJNum(1). [1.5]exhausts memory from five bytes of input.parse_valueconsumes nothing at the., andcorelib/json/json.ty:81-92advances only on,or], soJNum(0)is pushed forever. Not a slow parse — an unbounded one, from input a user could paste by accident.[{"a":1.5}]exits 0 with a fabricated column. The leftover.5}]is read as the next KEY. This is the shape a table reader actually meets, and it is the one with nothing to notice.
The root cause is not the missing float path — it is the missing error channel. corelib/json/json.ty@parse returns Json, not Result(Json, E), so no caller can ask whether a parse succeeded. corelib/json/json.ty:12-13 documents the parser as lenient and "failing closed to JNull", and it is lenient in the sense that it always returns something; closed is not what an OOM or an invented key is.
What it cost, measured. tycho-q cannot inherit any of that — a query tool whose worst case is returning wrong rows cannot be built on a reader that invents columns — so tools/tycho-q/main.ty validates the raw bytes *before* handing them over. Two layers, and the second is load-bearing rather than tidy: a token alphabet, and bracket nesting, because the non-termination needs a byte parse_value consumes none of sitting at a value position inside an array, and requiring brackets to nest is what makes the two survivors (} and :) unreachable there. That is most of a second JSON parser, written to check the first. What it does not claim is full validation: [1 2] passes it and core:json reads two elements — lenient, but it terminates and invents nothing.
Cost to fix: make parse fallible and make parse_array unable to loop without advancing. Both are small and neither needs a language change. A float path is a separate, larger question that interacts with item 2, since core:decimal is the only exact numeric tower here and JNum is an int. This ranks first because every other item on this list is a cost paid by the programmer; this one is paid by the person reading the output.
2. ~~core:decimal has no div, so the ordinary averaging query has no answer~~ — CLOSED 2026-08-11 (fixed 2026-08-02); see the banner below
Pinned-by: test -f corelib/decimal/decimal.ty Pinned-by: make corelib
[CLOSED 2026-08-11 — fixed 2026-08-02 by commit
a8c761c; this banner had gone stale for nine days.] The finding below is left verbatim.divlanded in exactly the shape the finding asked for and nothing here re-litigated it:corelib/decimal/decimal.ty@divisdiv(a, b, scale, mode) -> Result(Decimal, DivErr), with both the target scale and the rounding mode named by the caller and no default for either.corelib/decimal/decimal.ty:59-60gives the two modes the finding demanded —HALF_UPandTOWARD_ZERO— and the second is there for the stated reason, thatcorelib/decimal/decimal.ty@rescalealready truncates toward zero and a lone half-up policy would silently disagree with it. Because aconstdoes not cross a package boundary here, both are also readable as calls (corelib/decimal/decimal.ty@half_up), which is whattools/tycho-qwrites. A zero divisor isErr(DivByZero), not the process abort the finding called "not an error message"; a negative scale and an unknown mode are errors too. One bignum long division does the work, so no float touches the path.
tycho-q's exact-only/is gone with it.tools/tycho-q/main.ty:240-252records the change: the query the finding said had no answer now has one, and the zero divisor it had to pre-check is anErrit can attribute to a row.Re-probed 2026-08-11 before this banner was written, because the entry had already been re-read once and pronounced open while the code existed:
1/3at scale 5 half-up is0.33333,2/3at scale 2 is0.67half-up and0.66toward zero,-2/3is-0.67(ties away from zero on the magnitude, sign reapplied after), and the three refusals returnErr(DivByZero),Err(BadScale -1)andErr(BadMode 9)rather than aborting. The fixture that pins all of this iscorelib/test/decimal/main.ty:62-89, and breaking the half-up comparison indivon purpose reddensmake corelib— recorded under the phase inplan.md.
corelib/decimal/decimal.ty has from_int, from_str, add, sub, mul, cmp, rescale, to_str, neg, abs and is_zero. No division. The package's own header says why, and the reason is right: division needs a target scale and a rounding policy and it has neither.
Three options, and all three lose. Rejecting / at parse time refuses 6 / 2, which has a perfectly good exact answer. Converting to float puts a lie in exactly the place core:decimal exists to prevent one, and puts it there quietly. Integer truncation is honest for VInt / VInt and has no answer at all when either side is a decimal, so it decides half the question.
tycho-q chose exact-only: / computes when the result is exact and errors when it is not, with the zero check *before* the operator because division by a zero value aborts the process (docs/spec/09-expressions.md:27-28) and an abort is not an error message.
The cost is large and this is not pretending otherwise. select total / count — the single most common reason anyone writes / in SQL — fails on almost all real data:
$ tycho-q 'select name, qty / 5 as h from sales.csv'
tycho-q: `/` is exact-only: 12 / 5 is not a whole number, and core:decimal has
no div, so there is no rounding policy to apply
Cost to fix: not div(a, b) but div(a, b, scale, mode), with both named by the caller, plus the rounding modes spelled out — at minimum half-up and toward-zero, since corelib/decimal/decimal.ty@rescale already truncates toward zero and a second policy must not silently disagree with it. It ranks second rather than first because the failure is loud: a caller who hits it knows.
3. ~~core:sort has no comparator-taking sort at all~~ — CLOSED 2026-08-10
Pinned-by: test -f corelib/sort/sort.ty Pinned-by: make corelib
corelib/sort/sort.ty@sort_by takes cmp: fn($T, $T) -> int. Bottom-up merge, stable, the ~35 lines this item said already existed — lifted from tools/tycho-q/main.ty@merge_sort and made generic over the element instead of over an index.
The stability point was the one worth testing, not the sorting: the test orders "b1", "a1", "b2", "a2" by first character descending, and a total-by-index comparator passes every other case here and returns b2, b1 on that one. Flipping <= 0 to < 0 in the merge was run on purpose and reverses both that case and the two-key struct case.
tools/tycho-q/main.ty@merge_sort still exists and is untouched — converting it to the corelib call is separate cleanup.
The original text follows.
Read against the signatures, not assumed. corelib/sort/sort.ty@by_key takes key: fn($T) -> int — the key is an int, so it can express no order that does not fit in one machine word, and this program's order ranks by kind and then by value across VStr and decimal.Decimal. There is no order-preserving injection of a string into an int. corelib/sort/sort.ty@asc and corelib/sort/sort.ty@desc are where comparable(T) over the values themselves and take ONE array and ONE direction, so order by a asc, b desc — two keys, two directions, same rows — fits no signature in the package. corelib/sort/sort.ty@argsort has the same ceiling.
So tools/tycho-q/main.ty@merge_sort exists: bottom-up, iterative, stable, comparator as a first-class function value, ~35 lines. Stability is a property of the merge, not of a tiebreak — the merge takes the left run on <= 0, and a total-by-index comparator would look identical on every test but reverse tie order under desc, so order by k desc limit 3 on a column with ties would return different rows by a rule nobody wrote down.
Cost to fix: one sort_by(xs, cmp: fn($T, $T) -> int) beside the existing entry points, ~35 lines that already exist in this tree. Any program ordering by more than one key, in more than one direction, or over a type with no comparable instance writes its own sort today, and each one gets its own answer to the stability question.
4. ~~Result(void, E) is not expressible, and a bare or_return is not a statement — one defect, three sightings~~ — CLOSED 2026-08-09
Pinned-by: make test
Both halves landed together, because this item was right that they are one defect. Result(void, E) is now writable: void is spellable as a Result's ok payload (and nowhere else), constructed by a zero-argument Ok() and matched by a bare Ok: arm; and f() or_return IS a statement, admitted exactly when the ok payload is void — which is the case this item describes and no other. An or_return statement over a real payload still fails, now with a message that names the type it would have dropped rather than the general "no effect" rule.
The item's own diagnosis held up under the fix. What it did not see is that the enabling change was two-layered: T_VOID doubled as the generic bind-vector's "unbound" sentinel, so void could not become a real type until that moved to T_UNBOUND (d868083). ROADMAP.md priced that re-sentinel as buying the spelling; it bought the *possibility* of the spelling.
tools/tycho-q/main.ty@check_cols — one of the three sightings — is untouched and still carries _ := check_cols(l, hdr) or_return. Converting it is optional cleanup, not part of the fix, and is deliberately not bundled here.
The original text follows.
A helper that can only succeed or fail must still return *something*, so it returns Result(int, E) and ends in a meaningless Ok(0). Then that meaningless value must be bound at every call site, because eat_kw(...) or_return alone on a line is refused: "a statement must be a declaration, assignment, or call — a bare expression has no effect". docs/spec/10-statements.md:16-18 names or_return among the forms refused as a bare-expression statement, so this is specified rather than a compiler quirk — but the specification is of the general rule, and the interaction with a void-returning fallible function is what bites.
Three independent sightings, in code with nothing else in common: the parser's keyword eater, and tools/tycho-q/main.ty@check_cols, a validator that only succeeds or fails and must therefore carry _ := check_cols(l, hdr) or_return through its own recursion. It is not a parser-shaped problem; it is what happens to every validator.
Cost to fix: either a unit-like type usable as a Result's success payload, or admitting or_return as a statement when the value is discarded. Both are language changes, which is why this ranks below three corelib items that are not.
5. ~~An enum cannot be asked which variant it is without binding a payload~~ — FIXED 2026-08-11: the is operator
Pinned-by: test -f tests/enum_is.ty Pinned-by: make test
Closed.
v is Variantis now abool-valued operator covering both halves of the gap — nullary and payload-carrying variants alike, binding nothing.isis a reserved word; the grep that decided that found zero uses ofisas an identifier in any.tyfile in the tree, so unlikepassit did not need to be contextual. The right operand is a variant name spelled exactly as amatcharm spells it, including the qualifiedpkg.Vform, and a name that is not a variant of that enum is a compile error naming both.Specified at
docs/spec/12-aggregates.md§19.8 anddocs/spec/09-expressions.md§13.2; fixturestests/enum_is.ty,tests/reject/enum_is_unknown_variant.ty,tests/reject/enum_is_not_an_enum.ty. Full evidence — the identifier grep, the emitted C, the negative control — is under the tickedisphase inplan.md.Two things this did NOT do, both deliberate and both recorded rather than hidden.
v == VNullstill works for a nullary variant, so that one question has two spellings; removing it would break working code for tidiness. Andisdoes not yet reachOptionorResult—r is Okis refused — which is filed as its own unchecked phase inplan.md, because those two do not use the->tagdiscriminator user enums do and widening it is not a one-liner.The confirmation triage that preceded the fix follows.
Triage 2026-08-11. Probed against the compiler at
77bd826. The finding holds: there is noisoperator (if v is VInt:→error: expected ':' before the block) and no tag accessor —src/tychoc.chas notag_ofor equivalent, andgit log -S'tag_of' -- src/tychoc.cis empty, so one never existed. A hand-writtenkind()is still the only way to get a variant as a value.Two things below are wrong and are corrected here.
1. *"a match arm must name a binder it never uses"* — it must name the right arity, not a name.
_is accepted:VInt(_): println("int")compiles and runs. The decoration is one underscore per arm, not an invented identifier. What is refused is dropping the parens entirely —VInt: return 1→error: VInt binds 1 value(s), got 0(src/tychoc.c:9641). 2. *"without binding a payload"* — a nullary variant needs no match at all:if v == VNull:compiles and runs.==is a working discriminator for the payload-free half of an enum. It stops at the other half:if v == VInt:→error: VInt carries a payload — write VInt(...)(src/tychoc.c:6960).So the real gap is narrower than the heading: a payload-carrying variant has no value-level discriminator, and
matchis the only test for it.tools/tycho-q/main.ty@kindis still the workaround, and the arms in it are(_)-cheap rather than name-cheap.
The original text follows.
There is no is, no tag accessor, and a match arm must name a binder it never uses. Value needs its tag constantly — every comparison rule, every arithmetic rule and the whole kind-rank order are "what kind is this" — so tools/tycho-q/main.ty@kind is the tag accessor the language does not have, hand-written once, with binders that are pure decoration.
Writing it once and switching on an int everywhere is much the lesser evil, and that is the finding: a sum type used as data rather than as control flow wants a discriminator, and the only way to get one is to build it. The alternative is that decoration at every site, which is where a reader stops being able to see which arms actually use their payloads.
6. ~~Two error types cannot share an or_return chain~~ — WRONG MECHANISM, corrected 2026-08-11; CLOSED by result.map_err_with the same day
Pinned-by: make test
Triage 2026-08-11. The load-bearing sentence below — "no function can propagate across the boundary" — is false, and was false when it was written.
result.map_err(corelib/result/result.ty) already converts aResult's error type, and the converted value feedsor_returndirectly. Probed:
fn both(s: string) -> Result(int, RErr): n := result.map_err(parse(s), FromParse) or_return # parse returns Result(int, PErr) return Ok(n) $ ./tychoc q6b.ty -o q6b built q6bWhat genuinely reproduces is the unconverted chain, and only that:
n := parse(s) or_return # inside fn both(...) -> Result(int, RErr) error: or_return propagates a PErr error, but the function's error type is RErr—
src/tychoc.c:6946-6948. So the rule is "no *implicit* conversion", not "no conversion".The second, smaller true claim:
map_errtakes a constant replacement (corelib/result/result.ty), so the original cause is discarded — the package's own header states that cost deliberately. But a cause-preserving combinator is four lines of ordinary Tycho, needs no compiler change, and composes withor_return. Probed end to end,Syntax(7)arriving asFromParse(7):
fn map_err_with(r: Result($T, $E), f: fn($E) -> $F) -> Result($T, $F): match r: Ok(v): return Ok(v) Err(e): return Err(f(e)) $ ./q6c off=7This is therefore not a language item. It is at most one generic function added to
core:result. The entry's closing worry — three error types paying "at every boundary, with no language feature to make it cheaper" — costs one wrapped call per boundary today, and the collapse-into-one-type pressure it predicts does not follow.CLOSED 2026-08-11 — the function is
result.map_err_with, exactly the four lines probed above, sitting besidemap_errincorelib/result/result.tywith the same name shape, the same argument order and the same doc voice. Its regression iswith_payincorelib/test/result/main.ty, which asserts the payload, not the variant:Err(Sized(9, "nine"))must arrive asErr(FromSized(9))and printcause 9. Swapping that one call to plainmap_err(pick(k), Plain)flips it toplainand nothing else — measured — so the assertion is one a constant replacement provably cannot satisfy.
The original text follows.
PErr (parse — carries a byte offset) and RErr (runtime — carries a column name or nothing) genuinely differ, and folding them would put a meaningless off: 0 on two thirds of the messages. But Result(T, PErr) and Result(T, RErr) are different types and there is no From-style error conversion, so no function can propagate across the boundary.
Here they meet only in main and the cost is one extra match. That is why it ranks sixth and not higher — but the cost is structural, not proportional: a program with three error types and a call graph that mixed them would pay this at every boundary, with no language feature to make it cheaper, and the pressure would be to collapse them into one type carrying fields that are meaningless for two thirds of its values.
7. ~~core:iter is unusable for a fallible pipeline stage~~ — FIXED 2026-08-11, both halves
Pinned-by: test -f corelib/iter/iter.ty Pinned-by: make corelib
Second half fixed 2026-08-11.
filter/count/anytakefn($T) -> boolandtry_filter'skeeptakesfn($T) -> Result(bool, $E); the nonzero-is-true convention is gone. The two diagnostics quoted further down are the OLD behaviour —iter.filter(xs, fn(x: int) -> bool: x > 1)is now the spelling that compiles. The paragraphs below are left as filed.
Fixed 2026-08-11.
corelib/iter/iter.ty@try_mapandcorelib/iter/iter.ty@try_filtership the fallible siblings, with exactly the arguments and order ofmap/filterand a callback returningResult. Both short-circuit: the firstErrends the walk and is returned unchanged, so there is no partial array and no later error shadowing the real one. The fixture iscorelib/test/iter/main.ty, whose two must-fail inputs each carry two failing elements plus a999that callsdie, so an implementation that continued past the first failure either reports the wrong element or kills the program. Both were observed: swallowing theErrintry_mapreddenedmake corelibwithtry_map walked past the first erroron stderr and three golden lines missing; a last-error variant probed separately reports7where the golden demands5.Not added, deliberately:
try_reduce,try_count,try_any. No caller in this tree needs them, and the entry's own motivating shape — a query engine's row filter and its projection — istry_filterandtry_map. The second half of this entry,keep/predspelledfn($T) -> intin a language withbool, is untouched: it is a breaking change to a shipped signature and belongs in its own revertible commit.
Triage 2026-08-11 — CONFIRMED, unchanged, and correctly sized. Both diagnostics below reproduce verbatim against the compiler at
77bd826;corelib/iter/iter.tystill declareskeep: fn($T) -> int(corelib/iter/iter.ty:5) and still has no fallible counterpart — its five functions aremap,filter,reduce,count,anyand nothing else. Its last two commits (4e58e14,43eb558) touched neither.The entry's "cost to fix: a signature, not a language change" is now verified rather than argued: the fully generic fallible map compiles and runs today, with no compiler change, inferring
$T,$Uand$Eat once —
fn try_map(xs: [$T], f: fn($T) -> Result($U, $E)) -> Result([$U], $E): out := [] for v in xs: push(out, f(v) or_return) return Ok(out) $ ./q7d 2The
boolhalf is equally cheap: the int-predicate signature has five call sites in the whole tree, all undercorelib/test/iter/andexamples/corelib/iter/, so flippingkeep/predtoboolcosts those five lines and two goldens.
Labelled a correction, because the first version of this finding was too broad and a probe disproved it. The row filter is the exact shape core:iter exists for, and it cannot be written with it:
$ ys := iter.filter(xs, fn(x: int) -> bool: x > 1)
error: argument 2 of 'iter__filter' is fn(int) -> bool, which does not fit the
parameter pattern
$ ys := iter.filter(xs, fn(x: int) -> int: chk(x) or_return)
error: or_return requires the enclosing function to return a Result, but it
returns int
The first draft read those two diagnostics as a language rule against fallible higher-order functions. It is not. A later probe declared the parameter type explicitly:
fn msort(xs: [int], f: fn(int, int) -> Result(int, E)) -> Result([int], E)
and it compiles, accepts both a named function and a lambda in that position, and or_return propagates a comparator failure out through it. So the true claim is narrower and lands somewhere else: it is corelib/iter/iter.ty@filter's signature. keep is declared fn($T) -> int, which pins the lambda's return type — a lambda's body is a single expression (docs/spec/11-functions.md:88) and its return type is fixed by the parameter pattern it is passed to, so there is no way to widen it to a Result and no way to write the handling match inline.
Two things follow, and the second is the one worth having. Every caller spells its predicate as a 0/1 int in a language that has bool. And core:iter has no fallible counterpart at all — no try_filter, no filter over fn($T) -> Result(int, $E) — so every stage of a query engine that can fail, which is most of them, is a plain for loop with or_return in the body instead.
~~Both halves are STALE, re-scored by probe 2026-08-22.~~
filtertakeskeep: fn($T) -> bool(corelib/iter/iter.ty:18), not an int, so the 0/1 spelling is gone; and the fallible counterparts exist —try_filter(xs, keep: fn($T) -> Result(bool, $E)) -> Result([$T], $E)(corelib/iter/iter.ty:27) besidetry_map(corelib/iter/iter.ty:12). The paragraph above is kept because the REASONING is still the lesson: the first draft read this as a language rule against fallible higher-order functions, and the correction to "it is one signature" is what stopped someone being sent to the compiler.
Cost to fix: a signature, not a language change, which is precisely what the correction bought. The broad version of this claim would have sent someone to the compiler.
Smaller than they looked once written down
Recorded because they were hit, ranked below the line because writing them out shrank them. Padding this list would make the seven above harder to act on.
- ~~There is no no-op statement, and the workaround is now known.~~ — CLOSED 2026-08-10.
passis the no-op. Contextual rather than reserved, which this entry did not anticipate and the ROADMAP got wrong:passwas already a variable name incorelib/test/testingandtools/prunner, so reserving it would have broken the runner that scoresmake test-fast. The workaround below (bind something nobody reads) still parses; nothing had to change. Original text: - There is no no-op statement, and the workaround is now known. An absent
whereshould print nothing, so amatcharm has no work — and an empty arm cannot be spelled (passis not a keyword; same "must be a declaration, assignment, or call" error). The section above already records that a match arm cannot be empty. What is new is the cheap answer for an arm in the middle of a statement sequence: a declaration IS a legal statement where a bare expression is not, so bind something —ok := sz,_ := 0. Only when the match is the whole body is the more expensive lift needed, giving every arm areturn;tools/tycho-q/main.ty@where_lineandtools/tycho-q/main.ty@limit_lineexist for that reason and no other. Real, and two lines each. - A cursor threaded by
inoutcannot also be passed by value in the same call.lex_string(s, n, pos, &pos)is refused: "variable 'pos' is passed to an inout parameter and also by value in the same call ... the by-value copy would alias the inout'd value". The diagnostic is good and the rule is right — this is the aliasing bug it exists to prevent, caught at compile time. What it leaves is a shape rule for anyone writing a lexer here: a function wanting both "where I started" and "where I am" derives the first from the second on entry. Cost: one line per lexer function, and no bug. - A bare path in
fromcannot be absolute.from /tmp/x.csvlexes the leading/as division and fails with "expected a source path afterfrom" — a diagnostic about division for a problem about quoting. A tool wart, not a language one:from '/tmp/x.csv'works, the escape hatch predates the problem, and the fix is in this program's lexer. Recorded only because the diagnostic points away from the cause. Re-scored by probe 2026-08-22 — REPRODUCES, with two corrections. The entry's own example no longer parses at all: the query isselect … from …now, notfrom … select …, so the shape to run istycho-q "select a from /tmp/x.csv". And the diagnostic has stopped pointing away from the cause — it readsunexpected token / (expected a source path after from), which names the right thing and merely shows/as the offending token. The quoted form returns the row. What is left is a lexer wart with a diagnostic that now describes it, which is a smaller entry than it was.
What did not go wrong, which is also data
- Recursive enums work, in both positions, and the phase that depended on it probed before writing.
EBin(int, Expr, Expr)(direct) andECall(string, [Expr])(through an array payload) both compile in apackage mainprogram, with amatchthat recurses into itself from either. Zero diagnostics. The AST is therefore direct recursion and not the index-into-a-node-array fallback the plan had budgeted for. OptionandResultplumbing needed no workaround anywhere.Option(Expr)andOption(int)are usable as struct fields over a program-local recursive enum,Noneinfers its type from the field it is assigned to, andSome(parse_expr(...) or_return)composes. Given items 4 and 6 above, this is worth stating: what is missing from theResultstory is a *void* payload and a *conversion*, not the plumbing.- First-class functions carry their weight. A lambda with a
Resultreturn type is accepted; a closure capturing two arrays compiles and works; capture is by deep copy at creation, so the sort's key matrix is copied once rather than per comparison.tools/tycho-q/main.ty@merge_sorttakes its comparator as a value and that is the ordinary way to write it here. - A qualified enum from another package works as a payload and as a parameter type —
VDec(decimal.Decimal)andjson.Json— and nested patterns over another package's enum match directly, qualified:Err(io.NotFound)works, bareNotFoundis "not a variant of io__IoErr", which is the right answer. core:decimalpreserves scale through a round trip.decimal.from_str("1.50")renders back as1.50, so a decimal literal survives byte-exactly and0.1 + 0.2is the three characters0.3— not0.30000000000000004and not0.30. There is no float anywhere in this program, and that is the reason a query tool could be written here at all.- The last of three phases compiled first try, zero diagnostics — including the comparator sort, the closure and the JSON reader. This matches what the two re-scorings above found and continues to contradict the picture the older half of this file paints.
Adversarial pass over the surface that shipped 2026-08-11 (head fa4f5fc)
Not a program being written — a hunt. Every feature that landed on 2026-08-11 had roughly one happy-path fixture and one reject fixture, so the combinations no fixture covers were probed directly: ~45 programs, compiled with ./tychoc and run. One real defect came out (struct Ok and its four siblings declaring cleanly and being unreachable — fixed in the same commit as this entry, see tests/reject/struct_named_ok.ty). The rest of what turned up is below.
8. ~~iter.try_map has no Result(void, E) shape, and says so from inside corelib~~ — CLOSED 2026-08-12: deliberate; re-probed 2026-08-13
Pinned-by: test -f corelib/iter/iter.ty Pinned-by: make corelib
A callback answering Result(void, E) is the natural spelling for "walk these and stop at the first failure, I want no values back" — validate each row, chmod each path, set_mtime each extracted file. try_map cannot express it, because its $U becomes void and its accumulator has no element type:
fn check(x: int) -> Result(void, string):
if x < 0:
return Err("neg")
return Ok()
fn main():
r := iter.try_map([1, 2], check)
corelib/iter/iter.ty:30: error: cannot infer the type of 'out' from this use
30 | push(out, f(v) or_return)
./main.ty:8: note: required from here -- this call instantiated the generic
8 | r := iter.try_map([1,2], check)
The note: is the day's other feature working exactly as intended — without 4a7cca0 this was a bare corelib line and nothing else. It is still a message about out, a local the caller has never heard of, for a problem that is "there is no try_each". The workaround is a hand-written loop with or_return, three lines, which is what try_map existed to remove. Not fixed here: adding try_each is a design decision about the package's shape, not a defect.
Decided 2026-08-12:
try_eachis DECLINED. The loop is the answer.Re-probed first, because the entry above predates
4a7cca0. The diagnostic half is already fixed: the refusal now prints the corelib line *and* anote: ... this call instantiated the genericnaming the caller's own line, so a reader is no longer sent only to a file they cannot edit. What remains is purely the question of whether the function earns its place.It does not. The whole of
try_eachis the loop it would wrap:
for x in xs: check(x) or_returnBuilt and run: this compiles today, short-circuits at the first
Err, and propagates it —[1,2,3]givesok,[1,-2,3]giveserr neg. Two lines, against one foriter.try_each(xs, check) or_return. That is the entire saving, and it buys a callback indirection in place of a loop body that can read the surrounding scope.
try_mapandtry_filterearn theirs because they *build* something — an accumulator, a push, an order to preserve, and a documented first-error rule that is easy to get wrong by hand (swallowing theErrreddenedmake corelibwhen it was written).try_eachreturnsResult(void, $E): no accumulator, nothing to get wrong, nothing to preserve. It is the loop with a function call in front of it.And no caller wants it. Across
corelib/,tools/,examples/andserver/there are 129or_returnsites; the number that are a baref(x) or_returndirectly inside aforloop — the exact shapetry_eachwould replace — is zero. The motivating examples in this entry (chmod each path,set_mtimeeach extracted file) are hypothetical; none of them is in the tree.6d498cfdeclinedtry_reduce,try_countandtry_anyon precisely this ground, and declining here is the same rule applied to the same evidence, not a new policy.Reopen this if a real caller appears. A second one in the same file would be the honest trigger; one hypothetical is not.
Re-probed 2026-08-13: the decline stands and its evidence grew. The repro above still fails byte-identically, and the loop still prints
okthenerr neg. Re-counted over the same four trees:or_returnis now on 158 lines of code (was 129), yet the count that decides this — a baref(x) or_returndirectly inside afor— is still 0, of 21 bareor_returnstatements tree-wide. Neither Go nor Odin is a precedent either way: Go'sslicesanditerhave no fallible-callback helper at all, Odin'score:slicemapper/filtertake an infallibleproc(U) -> V(theerrin their signature is the allocator's, not the callback's), and neither language hasResultto shape one from.
9. ~~A string across the FFI truncates at its first NUL, silently~~ — verdict upheld and gated 2026-08-12, re-probed 2026-08-13; see the banner below
Pinned-by: test -f tests/ffi/main.ty Pinned-by: make test
Scoped wrong when filed, and fixed 2026-08-12. This was entered as a
[string]defect. It is not: the scalarstringparameter and thestringreturn have the identical hazard, so it is the wholechar*boundary, in both directions. The narrow framing would have produced a narrow fix.
A Tycho string carries a length and may hold a NUL; a C char* carries no length and ends at its first NUL. Nothing in the ABI can hold the difference. Probed on one program (--shim over a C file reporting strlen):
A [string] C strlen sum = 4 # ["a\0c", "", "ee\xc8"] -- C sees 1 + 0 + 3
A tycho len sum = 6 # Tycho sees 3 + 0 + 3
A elem[0] byte 2 via C = 99 # 'c' IS there; only the length is lost
B scalar C strlen = 1 # the SAME hazard without an array in sight
B scalar tycho len = 3
C to_str len tycho = 3 # to_str(to_bytes([104,0,105]))
C to_str C strlen = 1
D bytes len C sees = 3 # bytes crosses as (ptr,len) -- INTACT
D bytes byte 1 = 0
The return direction truncates too: a C function handing back a static {'h',0,'i',0} yields a Tycho string of len 1, because the arena copy is strlen-bounded (runtime/tycho_rt.c@tycho_str_from_c, whose own comment already said so). A string *literal* cannot carry a NUL — \0 is rejected as an unsupported escape — so the value always arrives from chr(0), from to_str over a constructed bytes, or from C.
Verdict: documented, not enforced. Refusing at compile time is impossible (the content is a runtime value; refusing the *type* would ban the FFI's commonest parameter). Refusing at run time was measured and rejected: the borrow costs 0.0 ns — nothing is copied, which is its entire point — and a memchr guard over a 2000×64-byte array costs 5.5 µs, turning an O(1) borrow into an O(total bytes) one, while covering only the outbound half; a char* out of C offers nothing to compare against. A parallel length vector gives up the same property and breaks every existing callee.
bytes crosses as (ptr, len) and preserves interior NULs exactly — verified above — so it is the supported route, and docs/spec/14-ffi.md §24.1 now says all of this under "Interior NULs", covering the scalar parameter, the array element and the return.
The verdict holds for the boundary and does NOT hold for a validator built on it — core:regex was fixed 2026-08-12, not documented. All eight subject-taking entry points answered "no match" for a payload sitting behind a NUL (find = -1 where the same-run NUL-free control gave 4), which is a wrong answer to a security question, not a stated limit. The fix is the shape this entry says is supported: the subject crosses as (pointer, length) and REG_STARTEND bounds regexec by it (corelib/regex/regex_shim.c@rx_exec); where that extension is absent the shim dies loudly rather than truncating. The cost objection above does not transfer — the length is len(s), an O(1) header read, not a memchr, and 200,000 calls measured 57-60 ms after against 59-62 ms before. A NUL-bearing *pattern* is refused instead (regcomp has no length-bearing form anywhere). The general rule: a package whose answer is a yes/no about untrusted bytes cannot inherit "documented, not enforced" from the boundary it is built on.
Re-probed 2026-08-13: reproduces exactly, both directions, and the verdict stands. A fresh --shim program: "h" + chr(0) + "i" has len 3 while the callee's strlen returns 1; a C {'h',0,'i',0} comes back at len 1; a strlen-fold over ["h\0i"] sees 1, not 3. The same run shows the supported route intact — the identical value as bytes crosses at len 3, sums 209 (104+0+105), returns from C at len 3 with byte 1 == 0, and to_str brings it back at len 3. Those four legs were prose only; they are now pinned by tests/ffi/main.ty@nulparam (nulparam=3/1 nulret=1 nularr=114 ctl=2), which reddens if the boundary ever starts carrying the length.
Precedent, measured rather than recalled (go1.26.5, Odin dev-2026-04-nightly; same string, a real file named h in the cwd as the positive control):
Go C.CString("h\0i") -> strlen = 1 # truncates, silently
Go syscall.BytePtrFromString = EINVAL # refuses
Go os.Open("h\0i") = EINVAL # refuses, though `h` exists
Odin strings.clone_to_cstring -> strlen = 1 # truncates, silently
Odin strings.unsafe_string_to_cstring = 1 # truncates, silently
Odin os.open("h\0i") = nil # OPENS `h`
Neither refuses at the FFI boundary, so the verdict above matches both; and the split this entry already drew — boundary documented, validator enforced — is Go's, one layer up, at the stdlib call that names a resource. core:io was on Odin's side of that line until 2026-08-13: io.exists("h" + chr(0) + "i") was true and io.read of it returned the contents of h. That is the same class as the core:regex finding, not a defect of the boundary, and it moved to Go's side along with core:net and core:os — 21 path-taking entry points, 4 host-taking and 4 command-taking ones now refuse before anything is attempted (corelib/io/io.ty@has_nul), naming Err(BadPath) / Err(BadAddr) where there is an error channel and giving each call's documented "this did not happen" sentinel where there is not. core:path is exempt: lexical, reaching no char*. The three fixtures carry live positive controls — strip the guards and core:io reads HELLO through a truncated path, net.listen binds 127.0.0.1 and os.system runs printf NULBAD reporting exit 0 (measured 2026-08-13 against a guard-stripped copy of corelib/).
The length is at the boundary but is not in the ABI. A Tycho string is length-headered, so a shim reading ((const int64_t *)s)[-1] recovers 3 where strlen says 1 — probed at a constructed string, a literal and "". That is a private runtime layout, not a contract: a third-party C function cannot use it, and bytes is how the ABI says the same thing. It does not weaken the cost objection above either, because deciding *whether* a string holds an interior NUL still costs a scan, header or no header.
What did not go wrong, which is also data
isis single-eval and short-circuits correctly.make(&c) is VBon a call with aninoutside effect calls once;a is Some and boom(&c) is Someleaves the counter at 0 when the left side is false. Codegen emitsgen_expr(e->lhs)exactly once (src/tychoc.c@TK_IS).isinside a generic body substitutes per instantiation, and refuses the wrong enum at the call site that caused it.fn isFirst(v: $T) -> bool: return v is A1instantiated atB2gives "'A1' is not a variant of B" plus therequired from herenote.v is Somein a generic works atOption(int)andOption(string)from one body.- The uppercase-binding rule has no holes worth finding. Probed at: match arm binder, tuple destructuring,
forloop variable,keys()loop variable, lambda parameter,:=, and aspawntask binding — all refused with the same message. A struct FIELD and anextern fnparameter name may still be uppercase, and neither is a binding. [string]over the FFI is right everywhere else probed. Empty array, empty string elements, a 2000-element array built bypushin a loop, an array literal written inline, a[string]read out of a struct field, and the same array passed to two parameters of one extern (the callee sees one pointer, not two copies —p_tworeturned1303). Abounded[4]stringis refused at the call, which is right: it stores in.v, not.data.Result(void, E)composes. Threeor_returnsteps in a chain stop at the failing one;result.is_ok/is_err/err_orandr is Ok/r is Errall answer at a void payload.try_mapshort-circuits at the first, last and only element, and returnsOk([])for an empty input. Nestedtry_mapinsidetry_mapover[[int]]propagates the innerErrunchanged.io.set_mtimebehaves at every edge probed. A directory isOk(as documented), a symlink follows to its target, a missing path and""are bothErr(NotFound), a mode-444 file the caller owns isOk(POSIX: the owner may always set times), and a negative or year-2100 stamp round-trips throughio.mtime.- Operand and argument evaluation order is NOT left-to-right, and that is correct.
str(bump(&c)) + "|" + str(c)prints1|0andpair(bump(&e), e)prints1,0, while the f-string spelling prints1|1. This looked like the same defect77bd826had just fixed; it is not.docs/spec/09-expressions.md:168makes argument and operand order *unspecified* deliberately, anddocs/spec/appendix-f-impl-defined.md:14lists the f-string holes as the named exception. The feature that shipped is exactly as narrow as it says it is.
Re-scored against five programs written 2026-08-12 (head 77bd826)
Five ordinary programs, written the way a newcomer would write them rather than to probe anything. Two of the three findings below are combinations of deliberate decisions, not defects in either half — recorded because the cost only appears when the two meet, and neither entry proposes reversing either decision. The third is the opposite of a defect: an API that already exists and was not found.
10. ~~A two-key comparator cannot be written inline, and the composition that replaces it is invisible~~ — WITHDRAWN 2026-08-13
Re-probed by running programs at 49f49ae8. The entry's premise holds and its conclusion does not. A two-key comparator is written directly, as one named function with as many branches as it likes, and sort.sort_by takes it:
fn count_then_name(ac: int, bc: int, an: string, bn: string) -> int:
if ac != bc:
return bc - ac
...
byc := sort.sort_by(fruit, fn(a: string, b: string) -> int: count_then_name(freq.get(a, 0), freq.get(b, 0), a, b))
capt=[fig, apple, pear]
The step this entry missed is the last line: the closure does not have to *be* the comparator, only to reach the captured data and forward it. One expression is enough to call a function, so the single-expression restriction constrains nothing here — it never touches the branching, which lives in the named function. "A named function cannot substitute, because the comparator has to see the map being sorted on" is a non sequitur, and it is the whole basis of the claim below.
Where no capture is needed the closure disappears too, and the tree already proved this two days before the entry was written: corelib/test/sort/main.ty has ordered a struct by dept ascending and pay descending since e40f32d6 (2026-08-10) with a plain named comparator and no closure at all — corelib/test/sort/main.ty@dept_then_pay, passed by name. The entry cites sort_by while overlooking its own package's test of exactly the case it declares impossible, and sort_by's doc comment already advertises it (corelib/sort/sort.ty:66, "several keys, mixed directions").
The capture case the entry did reason about is now pinned as well, at corelib/test/sort/main.ty@count_then_name. Its negative control: delete the name tiebreak and capt becomes [fig, pear, apple] while every other line of the golden, bytwo included, is unchanged.
Precedent, run rather than recalled (go1.26.5, odin dev-2026-04-nightly; all three languages agree on eng9cy eng3ann eng3dee ops5bo):
| two-key comparator | can a comparator capture a local? | |
|---|---|---|
| Go | slices.SortStableFunc — block closure, or one expression via cmp.Or(cmp.Compare(x.Dept, y.Dept), cmp.Compare(y.Pay, x.Pay)), or a named func | yes |
| Odin | slice.stable_sort_by — block-bodied proc literal, or a named proc | no — proc literals are not closures; the map probe fails at compile time with Error: Undeclared name: m |
| Tycho | named fn, passed by name or called from a one-expression closure | yes |
So on the axis this entry is actually about, Tycho sits with Go and ahead of Odin: Odin cannot express the captured-map comparator at all without a global or a _with_data variant, and Tycho can.
What survives. Two smaller facts, neither of which supports the heading. First, the block-closure refusal still reproduces verbatim at 49f49ae8 — that is a real restriction, just not one that blocks this. Second, the stable-sort composition the entry documents is correct and remains a good technique for the argsort-style parallel-array shape corelib/test/wordfreq/main.ty:26-30 uses; it is simply not the *only* route, and calling it "the composition that replaces it" overstates a workaround into a necessity.
The original entry is preserved below.
10 (original, 2026-08-12). A two-key comparator cannot be written inline, and the composition that replaces it is invisible
Two decisions, each right on its own. Closures are single-expression only — fn(x: int) -> int: x + n, no block body:
f := fn(x: int) -> int:
if x > 0:
return x
return 0
./blk.ty:3: error: expected an expression
3 | f := fn(x: int) -> int:
| ^
And a ternary is refused by decision (CONTRIBUTING.md:176-177, on the "please don't propose them" list). Neither is in question here.
Their combination is: sort.sort_by(xs, cmp) (corelib/sort/sort.ty@sort_by) takes a comparator that must be one expression, and a two-key sort — count descending, ties alphabetical — needs a branch. A named function cannot substitute, because the comparator has to see the map being sorted on and Tycho has no globals, so the capture must be a closure. That closes every direct route, and the honest conclusion a reader reaches is "sorting by two keys is not expressible".
It is expressible, by composing two stable sorts — sort by the weaker key first, then stably by the stronger:
ks := arrays.sort(keys(m)) # weaker key: alphabetical
out := sort.sort_by(ks, fn(a: string, b: string) -> int: m[b] - m[a])
fig 5
apple 3
pear 3
date 1
apple before pear at equal counts: the alphabetical pass survives the stable count pass. Each sort now needs only one key, so each comparator is one expression and the restriction never binds.
This is already the idiom in this tree — corelib/test/wordfreq/main.ty:26-30 does exactly it with arrays.sort then sort.argsort_desc, and its header calls it out ("lexicographic sort first, then a STABLE argsort_desc on counts"). Nothing is proposed here. The cost is purely that the technique is invisible before you know it: the failure mode is a reader concluding the language cannot do something it does routinely, and neither the closure error nor sort_by's signature points at the composition.
11. ~~A first --shim C file must hand-declare tycho_int, because there is no header to include~~ — WITHDRAWN 2026-08-13
It does not have to declare anything. The entry reasoned from the corelib shims instead of writing one, and a shim written from scratch needs no typedef, no guard and no header — six lines, of which none are Tycho-specific:
#include <stdint.h>
int64_t shim_add(int64_t a, int64_t b) { return a + b; }
tychoc probe.ty --shim probe_shim.c builds and runs that, printing 1099511627777 for shim_add(1099511627776, 1). The reason is in the emitted C: the caller declares the prototype, extern tycho_int shim_add(tycho_int , tycho_int );, so the shim is never the place where the type is introduced. It only has to define a function of matching shape, and int64_t matches tycho_int by construction (docs/spec/appendix-f-impl-defined.md:45).
All 13 shims do carry the guarded typedef, and stripping it from one reddens make shim-check — but only because they *spell their signatures* tycho_int. That is house style, seven lines each (three comment, four code), chosen so they read like the spec's prototypes; int64_t throughout would compile identically. So the toll on a first-time author is zero lines, not the four claimed here.
What was really wrong is one row of a table. docs/reference/ffi.md mapped Tycho int to C long — right on LP64, 32-bit on Windows and ILP32. An author following it writes a truncating shim that no gate can see, because the shim and the generated prototype are separate translation units and C never compares them: the same probe against int shim_add(int, int) prints 1, not 1099511627777, with no diagnostic at any stage. Corrected there on 2026-08-13, along with the char row and the bytes length, both of which said long too.
make shim-check could not have caught any of this. It globs corelib/*/*_shim.c (scripts/shim_check.sh:6), so no --shim file outside the corelib is in its corpus at all.
Precedent, run rather than asserted (go1.26.5, odin dev-2026-04). Odin is the same shape as Tycho: foreign import plus a foreign block declaring shim_add :: proc(a, b: i64) -> i64 ---, against a plain int64_t C file with nothing Odin-specific in it. Go's cgo is *worse* on this axis, not better — the C function must be visible to the preamble, so with no header the author hand-writes the prototype inside the import "C" comment, and a mismatch between that prototype and the .c file is equally silent: preamble int64_t against int shim_add(int, int) compiles clean and prints 1. Neither toolchain ships the "header that defines the language's integer type" this entry wanted, because neither needs one.
The original text follows.
11. ~~A first --shim C file must hand-declare tycho_int, because there is no header to include~~ — CLOSED 2026-08-18
Pinned-by: test -f corelib/strings/strings_shim.c Pinned-by: make corelib
corelib/tycho.h exists and tychoc passes -I at the corelib root, so any shim anywhere can #include <tycho.h>. Proved from a temp directory outside the repo: a two-line shim with no typedef of its own builds and returns 42. The 13 in-tree shims include it by relative path instead, because tests/run.sh rolls its OWN cc line with no -I -- discovered by two pkg fixtures going red, which is the reason the relative form is the one in the tree and <tycho.h> is the one a user writes. The record of what the item said when open follows.
A shim is its own translation unit, compiled standalone — which is the whole point of make shim-check (Makefile:298, "every corelib <pkg>_shim.c must compile ON ITS OWN under -std=c11"). There is no generated tycho.h in the tree; find . -name 'tycho*.h' returns only build/tycho_rt_embed.h, which is the embedded runtime source, not a public header. So an author's first --shim file cannot include anything that defines the FFI integer type, and must write the guarded typedef by hand:
#ifndef TYCHO_INT_T
#define TYCHO_INT_T
typedef int64_t tycho_int;
#endif
corelib/strings/strings_shim.c:27-30. All 13 shims in the tree carry it (grep -l tycho_int corelib/*/*_shim.c | wc -l is 13, against 13 shims total), and its own comment says why the guard and the fixed width are both load-bearing: "to match the FFI ABI on ILP32/LLP64, not just LP64".
What a first-time author has to know, none of it written down in one place: the typedef, the #ifndef guard (two shims in one link would otherwise collide), the exact width, and <stdint.h> ahead of it. A generated tycho.h shipped beside the corelib would remove all four. Not built here — it is a new installed artifact with its own versioning and layout questions, and this entry only records the toll, which is one copied block per shim and is paid once.
What did not go wrong, which is also data
core:stringsis not missingsplit;splitis a builtin. This was written up as the day's most likely newcomer-bouncer — a word-frequency program hand-rolled a character scanner becausegrep -E '^fn .*-> \[string\]' corelib/strings/strings.tyreturned onlylines. That grep cannot find it:split(s, sep) -> [string]is a language builtin, specified atdocs/spec/16-builtins.md:151and registered atsrc/tychoc.c:6300@.name="split", so it is in no package at all.corelib/strings/strings.ty:171says so in a comment one line abovelines— "(split(s, sep) and find(s, sub) are language builtins -- not duplicated here.)" — andcorelib/test/wordfreq/main.ty:22is a word-frequency program in this tree using it, commented "# split is a builtin". Fifteen-plus files acrosscorelib/,examples/andtools/call it.
Behaviour, probed directly: split("a,b,c", ",") -> [a, b, c], split("", ",") -> [], split("a,,b", ",") -> [a, , b] (the empty field is kept), split("a,", ",") -> [a, ] (a trailing separator DOES add an empty element, unlike lines), split("a::b", "::") -> [a, b]. An empty separator aborts at run time by design (§29.12).
Adding strings.split would have been actively harmful, not merely redundant: split is on the shadowed-builtin list (src/tychoc.c@shadows_builtin), so a fn split in corelib/strings would have emitted the exact warning entry #B of this session removed — into every build importing core:strings, one of the most-imported packages in the tree. The friction is real but it is discoverability, not surface area: the builtins live in the spec and in the compiler, and a reader looking for a string operation looks in the string package. A fields(s) whitespace split was considered on the same evidence and declined for the same reason — split(s, " ") plus the strings.trim already used at corelib/test/wordfreq/main.ty:23 is the composition, and the tree's own dogfood is written that way.
12. ~~A for binding does not destructure a tuple, and a tuple is not indexable~~ — all three halves reproduce; the rule stays and the MESSAGE was the defect, fixed 2026-08-13
Pinned-by: test -f tests/reject/for_two_binders.ty Pinned-by: make test
Re-probed at c45df1bb, each half its own program: for k, q in xs: was refused with expected ':' before the block (caret on the comma), p[0] with can only index an array, a string, bytes, or a map (as a place), and the body-destructure form built and printed a1 b2. A fourth probe the entry did not run: for i, s in xs: — an index binder, the thing a second name usually means — is refused by the same message, so the foreach head takes exactly one name in every spelling.
The precedent the entry never checked runs against extending it. Measured 2026-08-13 (go1.26.5, odin dev-2026-04-nightly, over an array of a two-field struct), neither neighbour destructures the element in loop-binding position — they spend the extra binder on the index:
go for name, src := range xs -> binder1=int(0) binder2=main.pair({resolvable true})
odin for v, i in xs -> i is `int` (odin check, on a deliberate `s: string = i`)
go a, b := two() -> destructures, like Tycho's `:=`
So for k, q in xs: would not be "the := feature extended to a loop"; it would take the position both neighbours read as the index and give it a different meaning. := destructuring already carries the feature, and the entry's own k, q := p is one line.
What was a real defect is the sentence this entry ends on — that a reader "meets two different refusals before finding the form that works, and neither message mentions it". The first message now does:
error: a `for` binds one name -- write `for p in COLL:` then `k, q := p` in the body
The names in the fix are the ones the author wrote, and any binder count reaches it (for a, b, c in xs: too). Pinned by tests/reject/for_two_binders.ty; p[0] is left alone, since a positional accessor on a tuple is a type-system question this entry does not argue for. The original entry follows.
12. ~~A for binding does not destructure a tuple, and a tuple is not indexable~~ — CLOSED 2026-08-18: deliberate, and both halves PINNED
Pinned-by: test -f tests/reject/for_two_binders.ty Pinned-by: make test
The one-binder for was decided on 2026-08-13 with its own diagnostic, measured against both neighbours (Go's first binder and Odin's second are the INDEX, so a second binder meaning something else would take a slot every reader expects), and pinned by tests/reject/for_two_binders.ty. The second half was unpinned until today: tests/reject/tuple_index.ty now holds it. Destructure in an assignment, which is supported. The record of what the item said when open follows.
Found writing a dependency resolver: for name, src in [("resolvable", ok), ("cyclic", bad)]: is refused with expected ':' before the block, pointing at the comma. The same destructuring in an assignment is fine and is used earlier in the same program — item, deps := strings.split_once(line, ":") compiles, and corelib/strings/strings.ty@split_once exists to be consumed that way. So the construct is available in one binding position and not the other.
The obvious fallback does not work either: for p in xs: binds, but p[0] is refused with can only index an array, a string, bytes, or a map (as a place). A tuple has no positional accessor, so a single-binder loop cannot reach the elements directly.
There is a working form, one line longer, and it is worth knowing because the two refusals above read like "arrays of tuples are unusable":
for p in xs:
k, q := p # destructure the loop variable in the body
println(k + str(q))
Probed at head 2f407ed: the for k, q in xs form fails to parse, the p[0] form fails to resolve, and the body-destructure form builds and prints a1 b2.
Not proposed as a change. := destructuring already carries the feature, so extending it to a for binding is a parser question rather than a type-system one — but the cost today is that a reader meets two different refusals before finding the form that works, and neither message mentions it.
13. A package-qualified function was not a value, and the message said the name did not exist — FIXED
Pinned-by: test -f tests/pkg/fnval/main.ty Pinned-by: make test
Found writing a fold over core:decimal. A local function is a first-class value and the tree's own fixture says so (tests/pkg/fnval/main.ty covers the same-package case). The cross-package spelling was refused:
println(str(apply2(math.min, 2, 3)))
./main.ty:9: error: package 'math' has no variant or const 'min'
The asymmetry is what made it expensive. math.min exists, and calling it one line earlier compiles. The message names the one thing that is not wrong, so the reader goes looking for a typo. Worse, it was the SAME message for three different situations — a genuine typo, a generic function, and a perfectly valid non-generic one — so it could not distinguish "you misspelled it" from "this form is not supported here".
The first guess was that genericity was the cause: math.min is fn min(a: $T, b: $T) -> $T where comparable(T), and a generic function genuinely has no single value form. That guess was wrong. A non-generic package function was refused identically:
println(apply1(strings.to_upper, "hi")) # strings.to_upper(s: string) -> string
./main.ty:9: error: package 'strings' has no variant or const 'to_upper'
So the restriction was incidental, not designed. docs/spec/09-expressions.md §13.6 says a lambda "and a named function" both produce function values and lists exactly two exclusions — an inout parameter, and comparability. A package-qualified name was never excluded; the resolver's E_FIELD arm simply tried const, then variant, then died, and never asked whether the name was a function. The machinery it needed was already three hundred lines away in the E_IDENT arm, which had handled the *same-package* mangled form since tests/pkg/fnval was written — including the __clo thunk, which is emitted per mangled name and so needed nothing new.
Verdict: CONTAINED, implemented. One block in one arm, reusing that machinery. A non-generic pkg.fn is now a function value (tests/pkg/fnvalcross/). A generic one is still refused, because there is no instantiation to take — but it now says so and names the workaround:
error: 'math.min' is generic, so it has no single function value -- there is no
instantiation to take. Wrap it in a lambda that fixes the types, e.g.
fn(a: int, b: int) -> int: math.min(a, b)
The genuine-miss message was widened to "variant, const or function" and gained the did-you-mean the call path already had. Both are pinned by # expect: fixtures (tests/reject/pkg/fnval_pkg_generic/, .../fnval_pkg_missing/).
Still open, filed rather than fixed: a *local* generic used as a value dies unknown variable 'mymin'; did you mean 'main'? — the same false-message shape, in the E_IDENT arm, which does not consult the generics registry. And (pkg.fn)(x) — the parenthesised immediate call — dies unknown variable 'opsx', because a call-on-expression resolves its callee before the package logic runs. Neither is the reported shape; both are in plan.md.
Re-scored against tools/tycho-db, 2026-08-12 (head b7c58a36)
Six packages, ~3k lines, a WAL, an index and a TCP server. It found one real corelib gap (no fsync, closed by io.sync in 4a0a6116) and two papercuts. It also produced two *false* findings — the package-import rule and the cross-package match-arm rule were both already documented, and were reported as discoveries by people who had not read docs/reference/packages.md:118 and docs/spec/12-aggregates.md:991. That is the same failure as a stale document, pointed the other way, and it is why both are named here.
14. ~~pass exists and nobody reaches for it~~ — CLOSED 2026-08-12 by placement
Pinned-by: make test
Writing tools/tycho-db/main.ty, a match arm that ignores a flush result was written as a call to a hand-rolled fn nop(): return. pass is the language's no-op statement, shipped for exactly this, and reads as intent:
match store.flush(db):
Ok(x): pass
Err(e): return Err(store.err_str(e))
Probed at head c56fbf6d: the pass form builds. Nothing was broken — the dummy function worked — so no gate could ever have caught it.
The shape is split's (entry #8 of the previous scoring): the feature exists, and nothing points at it from where a writer needs it. The remedy is documentation placement, not syntax.
Where it was, checked 2026-08-12. The claim above that "the language reference for statement positions is not where pass is introduced" was half wrong, and the true half is worse. pass WAS in the statements chapter — as a subordinate clause of one sentence about blocks not being empty (docs/spec/10-statements.md:12-22), with no heading, no example and no entry in any list of statement forms. A reader scanning §14's headings for what they may write saw if, match, for, break, continue and return, and never saw pass. Its only other homes were docs/spec/appendix-b-keywords.md (a contextual-keyword table read by nobody looking for a statement) and docs/spec/appendix-e-conformance.md (a fixture index). It appeared nowhere in docs/reference/, the reader-facing guide, at all — including docs/reference/basics.md's "Control flow" section, which lists break and continue and is precisely where the writer who hand-rolled nop would have looked.
Where it is now. docs/spec/10-statements.md §14.1.1 — its own numbered subsection, with the grammar line, a compiled match-arm example, and the contextual-not-reserved rule; §14.1's clause now points at it. And docs/reference/basics.md's "Control flow" section, where it joins break and continue in the keyword block and gets the same example plus a link to §14.1.1. Numbering below §14.1 is untouched, so no cross-reference moved. Nothing about the language changed.
15. ~~or_return on a Result(bool, E) is a three-time papercut~~ — CLOSED 2026-08-12; the entry's own reasoning was wrong
Pinned-by: test -f corelib/test/io/main.ty Pinned-by: make corelib
or_return yields the Ok payload, so on a Result(bool, E) it produces a bool the statement discards, and the compiler refuses:
error: `or_return` here produces bool, which this statement discards -- bind it (x := ... or_return)
The message is good — it names the remedy on the same line. It is recorded because of the *rate*: it was hit three separate times on 2026-08-12, by three different pieces of work (the hexed probe program, the io.sync wiring at five sites, and tycho-db's parser), each time by someone who had already read the rule. io.write_bytes, io.write_at and io.make_dir all returned Result(bool, IoErr) when this was written, so the common shape "do the write, propagate the error, ignore the bool" cost a binding every time.
The paragraph that stood here said this was "not proposed as a change, because the alternatives are worse: a Result(void) io would lose the 'did it write' answer". That was wrong, and nobody had read the four bodies. write_bytes, write_at, set_mtime and sync each returned Ok(true) on the success path and an Err on every other, so there was no "did it write" answer to lose — Ok(false) was unreachable in all four, and the bool the caller bound could only ever be true. The cost was not known and deliberate; it was a placeholder nobody had questioned.
Closed 2026-08-12: the four now return Result(void, IoErr) and io.write_bytes(p, b) or_return is a statement. Six call sites moved (tools/tycho-fetch, tools/tycho-ar, tools/tycho-kv, tools/tycho-db's store and log, corelib/test/io), each a match arm losing an unread binding — no guard was deleted, because no caller had ever written one against the impossible Ok(false), which is itself the evidence that the payload said nothing. corelib/test/io/main.ty@durable_write chains all four under bare or_returns so the shape is a compiled assertion rather than a claim.
is_dir, make_dir and remove KEEP their Result(bool, IoErr), and the distinction is the whole point: their Ok(false) is a real second answer — "it was already a directory", "there was nothing there" — reached by a real input. The test is not "does the bool look redundant" but "can the false arm happen".
Re-scored against tools/tycho-flow, 2026-08-12 (head 47b6d5b7)
A concurrent pipeline engine over generic channels: stages, fan-out, typed hand-offs. Two findings, one a real compiler defect with the worst failure mode a transpiler has, one a deliberate guard whose cost had never been written down.
16. ~~parallel for did not capture a function value — the error escaped to cc~~ — CLOSED 2026-08-12
Pinned-by: test -f tests/conc/parfor.ty Pinned-by: make test
Calling a fn-typed local from a parallel for body compiled to C that would not build:
fn apply_all(f: fn(int) -> int, xs: [int]) -> int:
total := 0
parallel for i in 0..<len(xs):
total = total + f(xs[i])
return total
x.c: In function 'h___par0': error: 'h_f' undeclared
tychoc accepted the program and the user was shown a diagnostic about generated source they never wrote, naming a mangled C identifier and a line in a file they had not asked for. That is the failure mode a transpiler exists to prevent, and no gate covered it: tests/conc/parfor.ty and the other parfor fixtures all call *global* functions, which resolve inside the lifted chunk proc and need no capture at all.
Why the scan missed it. A call's callee is not a child expression of the E_CALL node. f(x) keeps the name in sval, and o.f(x) keeps the receiver in qual, because the parser cannot tell that form from a package call (src/tychoc.c@parse_postfix). src/tychoc.c@pf_scan_expr walked lhs, rhs and args, so it saw neither — an int or an array in the same body was captured correctly, because those appear as E_IDENT children. The asymmetry is the tell: the *lambda* capture analysis had covered the sval half since it was written (src/tychoc.c@collect_idents, with a comment saying exactly why), and the parfor scan — written later, against the same AST — did not.
Refusal was never on the table. Nothing about a fn value resists lifting: src/tychoc.c@type_is_heap already answers 1 for IS_FUNC and copy_into re-homes a closure's env into the chunk task's arena, which is precisely why the fs := [f] array workaround people found by accident worked. The fix is one E_CALL arm feeding a new src/tychoc.c@pf_capture_name, which takes a name rather than a node and applies the same task-handle and handle rejections the E_IDENT path does; non-locals (global fns, builtins, enum constructors, real package qualifiers) fail vars_find and are dropped, so nothing that used to compile stops.
tests/conc/parfor_fnval.ty covers all four spellings the report named — a parameter, a local holding a plain fn reference, a local holding a closure with a heap capture, and a struct field — as reductions, so the answers are chunk-count independent, and it runs under ASan/UBSan/TSan like the rest of the lane. Stashing the fix and rebuilding reddens it on every spelling (h_f, h_g, h_scale, h_lbl, h_o undeclared).
17. Item($A) is rejected where Item($T) is accepted — verdict: deliberate and permanent, now documented
Pinned-by: make test
A generic aggregate may be applied only to its own declared parameter *names*:
struct Item($T):
v: $T
fn relabel(it: Item($A), f: fn($A) -> $B) -> Item($B): # refused
return Item(f(it.v))
The diagnostic is explicit and names both legal forms, so this reads as a guard rather than an oversight — and it is one (src/tychoc.c@parse_type, the has_typaram(args[i]) test after the self-reference shortcut).
What the guard actually tests, checked at the source. Not scope: each argument is compared against the type parameters of the *declaration being applied*. Item($T) inside a generic function passes only because a $Name interns to one program-wide type (src/tychoc.c@typaram_of), so the function's $T and the struct's $T are literally the same Type, and the application takes the recursive-self-reference early return that keeps the template for subst_type to finish. Rename either and it fails: Item($U) in fn relabel(it: Item($T), f: fn($T) -> $U) -> Item($U) is refused on the return type, verified 2026-08-12. So the accepted spelling works by name coincidence, which is exactly why the restriction is invisible until a stage needs to change the parameter.
The cost, measured in tools/tycho-flow. Every generic stage had to be endomorphic — same instantiation in and out — because a type-CHANGING stage (Item($A) -> Item($B)) cannot be spelled. Tuples were the obvious escape and are not one (see below).
Verdict (b): deliberate and permanent, and it is now in the spec. docs/spec/05-generics.md §7.3 previously stated the rule in one sentence and said nothing about why or what it costs; it now carries the reason (an applied aggregate type is one interned id naming the template or a completed instance, with no third form holding pending arguments), the endomorphic-stage consequence, and the note that Item($T)'s acceptance is the recursive path rather than scoping. Lifting it means introducing a deferred-application type and teaching every consumer of an aggregate type about it — not the five-arm shape of 4a0a6116-era fixes, and not planned.
18. ~~(int, $T) does not match a (int, int) argument~~ — CLOSED 2026-08-12
Pinned-by: test -f tests/generic_channel.ty Pinned-by: make test
Found while looking for a way around #17. This one is not deliberate:
fn first(p: (int, $T)) -> int:
return p.0
first((1, 2)) # error: argument 1 of 'first' is (int, int),
# which does not fit the parameter pattern
Channel((int, $T)) against a Channel((int, int)) fails the same way, and the channel is incidental — the bare tuple above fails on its own, so the miss is tuples, not the Channel arm added in f0fd19e5.
Size: the same five-arm shape as that commit, and its diff is the template. src/tychoc.c@match_type, @subst_type, @has_typaram, @type_mentions_tp and @type_mangle_ident each descend into arrays, Option, Result, maps, channels and fn types; none has an IS_TUP case, though src/tychoc.c@type_is_heap shows the recursion is already written elsewhere in the file. Expect one arm each plus a fixture in the shape of tests/generic_channel.ty — a tuple parameter at a scalar and at a heap element, and a nested Channel((int, $T)). Filed rather than implemented: it is its own change with its own gate run.
Closed by the arm audit below, which found the estimate right on shape and short by one: the five arms landed as written, and soa needed the same treatment plus three emit-loop guards. tests/generic_tuple_param.ty is the fixture.
19. A generic struct's [fn($T) -> $T] field escaped to cc — fixed
Pinned-by: test -f tests/generic_fn_array_field.ty Pinned-by: make test
Found writing tools/tycho-flow/graph/, whose header recorded it as a reason the pipeline holds four separate fn fields instead of an array of steps.
struct Plan($T):
steps: [fn($T) -> $T] # x.c: unknown type name 'FnC0'
The asymmetry, in one line. A fn($T) -> $T typedef is deliberately NOT emitted — src/tychoc.c:17224 skips any function type mentioning a type parameter, because $T lowers to void and a void parameter is invalid C. But the composite-array BODY loop emitted struct TychoArrC0_ { FnC0 *data; } for the template's dead [fn($T)->$T] anyway, naming the typedef that was just skipped. Every other g_arrtypes loop in the file — the op prototypes, and the three body loops — already guarded with has_typaram(T_ARRC_BASE + i); the body loop was the one that did not. That is the same shape as f0fd19e5's five missing IS_CHAN arms: a recursion complete everywhere but one site.
The fix is that guard, added. The tag stays forward-declared and simply never completed, which is what a template that is never instantiated wants; instances intern their own concrete FnC and are unaffected. The stale comment claiming the recursive-generic-enum case NEEDED that dead body was corrected in the same commit — it did not.
The SIGSEGV in that header was never real. graph/graph.ty claimed the same declaration reached from a second package made tychoc die with SIGSEGV before emitting. It does not. Re-probed three ways — across packages, with the struct never instantiated, and with four fn fields alongside the array — every arrangement produced the identical FnC0 cc error and exit 1, never a crash. The header now says so; a false crash report in a source comment costs more than the bug it describes.
tests/generic_fn_array_field.ty pins two instantiations (int and string, proving each instance gets its own FnC) plus an uninstantiated template, which failed the same way with no value of the type anywhere in the program.
Type-walker arm audit, 2026-08-12 (head 75ef1466)
Three compiler bugs in one day shared one shape — a recursion or a guard present at four sites and missing at the fifth (f0fd19e5's five IS_CHAN arms, 75ef1466's one has_typaram guard of five loops, 58134fcd's callee case in one AST walk but not its sibling). This is the deliberate sweep for the rest, done by rebuilding the type-kind matrix from the source and writing a probe program for every hole rather than reasoning about which look suspicious.
Eight holes probed, three real. The ragged matrix was a bad predictor: the two holes the brief flagged as "silent-wrong-answer, probe hard" turned out to be cosmetic, and the one it flagged as merely-generalising turned out to have a sibling nobody had listed.
| Hole | Probe | Verdict |
|---|---|---|
TUP in the five generic walkers | second((1, 7)) | real — FRICTION #18, fixed |
SOA in the same five | soa [Box($T)] param | real — reached cc as S_Box, fixed |
qual in collect_idents | m.get(k) inside a lambda | real — reached cc as h_m undeclared, fixed |
CHAN in type_is_heap | d := c; send(d, 5) | deliberate-and-correct, commented |
FUNC in type_mangle_ident | gid(add) | benign: unique but unstable name |
STRUCT/ENUM/FUNC in type_mentions_tp | unbox(Box(7)) | benign: over-names, cannot collide |
soa [$T] | direct | unreachable — refused at parse (src/tychoc.c@parse_type) |
| channel in an aggregate | [Channel(int)] | unreachable — refused by chan_container_err |
20. soa [S($T)] escaped to cc — fixed
Pinned-by: test -f tests/generic_soa_param.ty Pinned-by: make test
The soa column looked like a non-question: a soa element must be a struct, so soa [$T] cannot be written and there is nothing for a generic walker to descend into. That is true and it is not the whole answer — a generic struct *is* a struct at parse time, so soa [Box($T)] parses, and then:
fn count(s: soa [Box($T)]) -> int:
return len(s)
x.c: error: parameter 3 ('v') has incomplete type
static void Soa0_push(Arena *a, Soa0 *s, S_Box v) {
has_typaram had no IS_SOA arm, so the template-only type was not transient and the three g_nsoatypes emit loops stamped out helpers naming S_Box, the template struct, which has no C body. subst_type left the parameter a template soa and match_type could not bind $T from a soa [Box(int)] argument, so even the accepted half was wrong.
This is 75ef1466 again at a different table: the g_nchantypes emit loop has carried a has_typaram skip since f0fd19e5, and every g_arrtypes loop got one at 75ef1466; the soa loops had none. Checking the emit loops is now part of the fix, not a follow-up — an arm added to has_typaram only helps where something consults it.
tests/generic_soa_param.ty pins two element types plus a never-instantiated generic, which is the emit-loop guard specifically.
21. A lambda did not capture a method-call receiver — fixed
Pinned-by: test -f tests/lambda_capture_qual.ty Pinned-by: make test
The mirror image of 58134fcd, and the reason to compare sibling walks arm-for-arm rather than fix the one that was reported:
m : [string: int] = ["a": 1]
f := fn(k: string) -> int: m.get(k, 0)
x.c: In function 'h___lam0': error: 'h_m' undeclared
58134fcd taught src/tychoc.c@pf_scan_expr both names a call carries outside its child expressions — the callee in sval and a method receiver in qual — and its commit message says in as many words that the lambda side "already does the sval half". It did. It did not do the qual half, and nothing in the tree tested it. So the fix that closed the asymmetry in one direction left it open in the other, one function away.
The surface is wide, not a corner: every receiver form routes through qual — builtin UFCS (xs.len()), map .get, user UFCS methods, fn-typed struct field calls, user subscripts, and channel/task methods. Any of them, read inside a lambda over a captured local, produced a cc error naming a mangled identifier in a file the user never wrote. tests/lambda_capture_qual.ty takes one case per resolver arm, because only the capture list is shared between them.
The three that were not defects, so the next audit does not re-litigate them
Each now carries a one-line comment at its site.
type_is_heap has no IS_CHAN arm, and must not. A channel is a shared handle: d := c has to alias the one queue, and a deep copy would silently split a rendezvous in two — the probe sends on d and receives on c. It is also unreachable through the aggregate arms, since arrays, tuples and structs all refuse a channel element.
type_mangle_ident's t%d tail is unique, just unstable. The brief expected mangling collisions from the missing FUNC and TUP arms. There are none and there cannot be: the type ranges are disjoint, so no two types share a number. What the fallback costs is a name that moves with unrelated edits (gid__t16384), which is the same reason f0fd19e5 added the CHAN arm — so TUP and SOA arms went in with the rest, and FUNC is left as the one remaining case with a comment saying the tail is a naming choice, not a hazard.
type_mentions_tp cannot collide by missing an arm. Its answer decides whether an unpinned $T's binding is appended to the instance name. A false negative therefore *adds* a redundant suffix (unbox__Box__int__int) and a collision would need a false positive, which no missing arm can produce. TUP and SOA arms went in to drop the redundancy; STRUCT/ENUM/FUNC stay out, because recovering $T from a generic instance's provenance is match_type's job and duplicating it here buys nothing.
What the audit says about the method
Rebuilding the matrix was worth the time and reading it was not. Of the four candidates the ragged matrix suggested, two were real and two were noise, and the third real defect — #21 — is not in the matrix at all, because it is on the expression side. The generalisable rule is the one 58134fcd and #21 share: when a fix closes an asymmetry between two walks, check the other direction in the same commit. Both of this audit's expensive findings escaped to cc, which is the failure mode a transpiler exists to prevent, and neither had a gate.
Found by tools/tycho-sheet, 2026-08-12 (head 6f7954a9)
A spreadsheet is the first program in this tree that has to save a float and read it back. It could not use str, and the workaround it shipped — tools/tycho-sheet/cell/dtoa.ty@render, a strtod-verified shortest-decimal renderer written in Tycho — is the evidence: a tool does not reimplement a builtin unless the builtin is wrong.
22. str(float) emitted text the program could not read back — fixed
Pinned-by: test -f tests/float_roundtrip.ty Pinned-by: make test
runtime/tycho_rt.c@tycho_float_to_str formatted with %.15g. Fifteen is DBL_DIG, which guarantees text -> double -> text; binary64 needs DBL_DECIMAL_DIG = 17 for double -> text -> double, which is the direction str is used in. Three measured consequences, worsening:
str(x) | emitted | reading it back |
|---|---|---|
0.1 + 0.2 | 0.3 | a different double |
9007199254740992.0 (2^53) | 9.00719925474099e+15 | not an integer any more |
DBL_MAX | 1.79769313486232e+308 | above DBL_MAX — strings.parse_float refuses it as Overflow |
The last one is the sharp edge: str produced a decimal that this project's own parser rejects, so a value at the top of the range could be printed and never read. Nothing warned; the text looked fine.
Fixed by shortest-round-trip, not by %.17g. The renderer now tries %.15g, %.16g, %.17g and returns the first that strtod reads back unchanged. Both options fix every row above; the choice was about what happens to the values that were *already* correct.
%.17gis onesnprintfand makes every float print at full width —str(0.1)becomes0.10000000000000001.stris user-facing output here.- Escalation keeps the short text wherever 15 digits already round-tripped. Measured: of 660
make testfixtures, exactly one golden line moved (0.1+0.2, intests/float_str_locale.out, once per locale block), andmake corelib,make corelib-examples,make conc,q-check,ed-check,db-checkandflow-checkall stayed green with no re-record at all. A blanket%.17gwould have moved every one of those that prints a float.
The only other movement in the tree was five lines in tools/tycho-sheet/expected.out, all in the transcript that *reported* this bug — three str columns next to a render column that already had the right digits, and the two-line probe whose whole job was asserting str was lossy. That probe is inverted, not deleted: it now reddens if the fix is reverted. It is the one lane in the tree that could have caught this, and it did.
Cost, measured (300000 values, gcc -O2, three runs, this box): %.15g 134-154 ns/call, %.17g 145-149, escalating 423-448. About 290 ns, paid only by values that need it — a first-try hit costs one strtod more than the old code. The candidate is always checked, so the digit count never has to be guessed right; a wrong guess costs a retry, never a wrong number.
What did not change. The uselocale guard and the ".0" suffix for integral floats are both untouched, and both still have to be: make locale-check passes, str(3.0) is still 3.0, and the suffix scan is still sound only because the separator is known to be .. Formatting and re-parsing happen inside the same locale on both legs, so the fallback leg compares comma-decimal text against comma-decimal strtod.
f32 prints longer now (str promotes to double, and an f32's exact double value usually needs 17 digits). No golden in the tree moved for it, and the new text is the honest one: it round-trips the double str was handed.
Locked by tests/float_roundtrip.ty, whose rt15= column is the negative control — it re-runs the same check against a forced %.15g through tycho_test_float_roundtrip_prec, so the five zeroes in that column are the old defect reproduced in the golden. Without it a column of rt=1 would only prove the hook is cheerful.
23. strings.parse_float refused every subnormal — fixed
Pinned-by: make format-diff
Measured before the change: parse_float("2.2250738585072014e-308") was Ok and everything smaller was Err(Underflow) — 5e-324, 1e-320, the lot. That boundary is DBL_MIN, the smallest *normal*. Every subnormal, a third of a binary64's exponent range, was unreadable.
Was it deliberate? Half. The Underflow variant carried a comment saying the library "would round it to 0 or to a subnormal that has already lost digits, and either is a silent wrong value" — so somebody thought about it and wrote it down. The reasoning does not hold, and its own file says why: the PRECISION paragraph three lines above accepts parse_float("1234567890123456789012345") as Ok(1.2345678901234568e24). Losing digits to rounding is the documented, accepted behaviour at the top of the range. A subnormal is the *correctly-rounded nearest binary64*, exactly like that one. Two ends, two policies.
The cause was a flag test where a value test belonged. The shim did *status = isinf(v) ? OVERFLOW : UNDERFLOW for any ERANGE, and glibc raises ERANGE for gradual underflow as well as total underflow. Now the value decides: an infinity is Overflow, a 0 is Underflow, and anything else is returned. 1e-400 is still refused — it rounds to 0, and 0 is not what the caller wrote.
That test is also right about a case a issubnormal check would get wrong. glibc raises ERANGE for a decimal merely *near* the boundary even when the rounded result is normal — strtod("2.2250738585072012e-308") sets ERANGE and returns DBL_MIN. The old code refused that string; testing the value accepts it.
It was the other half of #22. str now emits the shortest decimal that reads back unchanged, so str(5e-324) is 4.94065645841247e-324 — a string the parser in the same standard library would not read. Neither fix alone closes the round trip; corelib/test/strings.out now asserts the pair with str -> parse_float -> the same double over 0.1+0.2, DBL_MAX, 5e-324, 2^53 and -0.0.
What moved. make corelib gained eleven lines and changed none. The visible consequence is in the spreadsheet that reported it: tools/tycho-sheet's demo had =1e-308 rejected as a *malformed number* and =1e-300/1e10 rendering #NUM!, both purely because of this. Both now hold their value, its 98411-value round-trip corpus lost its one permitted failure, and render()'s #NUM! arm became unreachable from any input. The arm stays — it is the fail-closed answer to "these digits do not name this value" — and the gate now declares it unreachable *and asserts nothing reaches it*, so the declaration reddens in both directions rather than becoming a quiet exemption.
Found by tools/tycho-sim, 2026-08-13 (head 8654391f)
24. the sink consume diagnostic described a rule the compiler does not implement — fixed
Pinned-by: test -f tests/reject/sink_consume_mentions.ty Pinned-by: make test
sink is the third feature no program in this tree uses. Reaching for it in a simulation's entity pools produced a refusal whose stated remedy was already satisfied. can_move_into_sink (src/tychoc.c@can_move_into_sink) declines adoption for four separate reasons and sink_arg_into collapsed all of them into one sentence:
'w' is consumed by a sink parameter but used again (or inside a loop); pass a copy you keep (y := w) or make this its last use
Measured before the change: each of len(w), w[0], bump(&w), w[0] = 9 and push(w, x) written before the sink call produced exactly that text — and in every one of them the sink call was the variable's last use. The rule the compiler implements is not last-use at all; it is count_reads_b(whole body) == 1, one textual mention anywhere in the function. "Reads" is a misnomer: count_reads_e (src/tychoc.c@count_reads_e) counts every E_IDENT occurrence and count_reads_b walks s->target, so an assignment target and a mutating call argument each count. A forwarded sink parameter, mentioned once and not in a loop, got the same sentence — neither clause of it true.
Fixed by splitting the string, not the rule. One arm per condition, in can_move_into_sink's own order: a sink parameter (never adopted, however declared), a call inside a loop, and a mention count that is not 1 — the last printing the count and naming one textual mention as *how* last-use is proved without dataflow. The escape route is now one that works: the loop arm no longer suggests a copy made in the loop, which is refused for the same reason (probed 2026-08-13). Three reject fixtures pin the three texts separately (tests/reject/sink_consume_mentions.ty, tests/reject/sink_consume_in_loop.ty, tests/reject/sink_consume_param.ty); each was proved to redden on its own arm and only its own. The fourth condition, !cv_arena(name), is unreachable: Tycho has no globals (a top-level G := [1] is a parse error), and both locals and params are cv_pushed, so every E_IDENT arriving here has an arena. It gets a fourth arm that claims nothing rather than a fixture.
Not fixed: the rule itself. One mention is a coarse stand-in for last use, and it is why an entity pool built with push cannot be sink-passed at all. Relaxing it needs a real last-use analysis; that is a language change, out of this scope.
25. ~~a keyword used as a field or a variable does not say it is a keyword~~ — CLOSED 2026-08-13
Pinned-by: test -f tests/reject/kw_as_field_name.ty Pinned-by: make test
Writing the systems in tools/tycho-sim/sys/ wanted a struct of flags saying which systems run, one per system. The obvious spelling does not compile, and the refusal names the wrong thing:
struct Cfg: spawn: bool -> error: expected a field name
spawn is a keyword, so the field-name parser rejects it — but the message describes the token as not being a field name at all, which is what you would also get from a stray , or a number. Nothing in it suggests the identifier is fine and merely taken. The same holds for a local: spawn := 1 gives expected an expression, pointing at the := rather than at the name.
The compiler already has the right sentence and uses it one place out of three. A procedure gets it (probed 2026-08-13, all four in a clean directory):
| written as | diagnostic |
|---|---|
fn spawn(n: int) | 'spawn' is a reserved keyword and cannot be used as a procedure name |
spawn: bool (field) | expected a field name |
spawn := 1 (local) | expected an expression |
parallel: bool (field) | expected a field name |
So this is not a missing analysis, it is one existing string not reused at the field-name and short-declaration sites.
Fixed by moving the guard down, not by copying it. The predicate is unchanged — a reserved word is still exactly a token whose lexeme keyword() maps back to its own kind — but it now lives in eat (src/tychoc.c@reserved_kw), which is the common path for *every* identifier position and already carries the human phrase for each one ("a field name", "a parameter name", "a variant name", "a struct name", …). One arm, templated on that phrase, covers all 33 of them; the special case in parse_fn was deleted and the procedure sentence it used to print is reproduced byte-identically by the template.
The local is the one site that genuinely needed its own arm. It never reaches an identifier position at all: every binding branch of parse_stmt gates on TK_IDENT, so a keyword falls straight through to the expression parser — which is why the caret sat on the :=. Its guard sits at the top of parse_stmt and covers kw :=, kw =, kw , and kw : <type>; the colon form requires a non-NEWLINE after it, or else: and select: would be caught by it (both re-probed and unchanged).
Three fixtures. tests/reject/kw_as_field_name.ty and tests/reject/kw_as_local_name.ty pin their own text, and each was proved to redden on its own arm and only its own. tests/kw_contextual_names.ty is the positive one and is the regression this change could plausibly have caused: §3.7's contextual identifiers (where, soa, range, sink, extern, package) are used as field, parameter and local names in one program, and a predicate broadened past keyword() would refuse all of them.
Worked around in the program by naming the field reinforce (tools/tycho-sim/sys/sys.ty@reinforce) — which is a fair name for per-tick spawning, so the cost here was the minutes spent learning why, not the rename.
Found by tools/tycho-make, 2026-08-13 (head 77bd826)
26. ~~a foreign enum's variant in a match arm is refused as if it did not exist~~ — FIXED 2026-08-13
Pinned-by: test -f tests/enum_bare_variant_local.ty Pinned-by: make test
Matching a corelib enum by its bare variant name is refused, and the message said the variant is not a variant of the type — untrue, and it sends you looking at the enum rather than at the spelling:
$ ./tychoc /tmp/a.ty -o /tmp/a
/tmp/a.ty:5: error: 'NotFound' is not a variant of io__IoErr
5 | NotFound: return "nf"
NotFound is a variant of io.IoErr (corelib/io/io.ty@NotFound). The rule is that a foreign enum's variants must be written qualified, and io.NotFound compiles and runs. Both halves of that were probed: the same program with every arm qualified builds and prints nf, and the identical shape over a *locally* declared enum accepts the bare form, so the qualification requirement is real and specific to the cross-package case. Nobody in the tree had hit it because every package that owns an error enum also owns the err_str that matches it, so the match is always in-package.
Fixed at both sites that could make the false claim, through one shared src/tychoc.c@die_not_variant: the match arm and the is operator each compared the written name against the *mangled* variant name only, so a bare foreign name missed and fell into the "not a variant" arm. It now recognises the name as this enum's, says the rule, and prints the spelling that works:
error: 'NotFound' is a variant of io.IoErr, but a variant of another package's
enum must be written qualified -- write 'io.NotFound'
A name that is a variant of nothing still reports as unknown — qualifying it would not help — and every enum in a diagnostic is now named as it was written (io.IoErr, not io__IoErr). The nested-pattern site (Err(NotFound)) was left alone: enum_variant_index there already accepts the bare form, so it cannot make the false claim, and a bare unknown name is caught one step earlier by the binding-name rule. Both probed at the fix.
Three reject fixtures pin the new text (tests/reject/pkg/foreign_variant_bare/, foreign_variant_is/, foreign_variant_unknown/), each proved to redden on its own # expect: line. tests/enum_bare_variant_local.ty is the positive one and is the regression this could have caused: a bare variant of a *local* enum, in a match arm and after is, must still be accepted, which no reject fixture sees.
27. ~~bounded parallelism over a graph read at runtime has exactly one expressible shape~~ — WRONG, WITHDRAWN 2026-08-13
This entry claimed that a work queue over a DAG read at run time was not expressible, and that a wavefront — parallel for over one depth of the graph, depths in order — was all the language allowed. That was false, and tools/tycho-make now runs a work queue.
What is actually expressible, and is what tools/tycho-make/build/build.ty@build does today: one coordinator and N stateless workers. The pool is a single spawned task whose body is a parallel for over a slot list, every chunk looping on the same jobs channel (tools/tycho-make/build/build.ty@pool). The coordinator keeps the indegree table, sends a job when a node's last dependency reports, and files outcomes by node index. A node starts when its own dependencies finish, not when its level does.
Where the reasoning went wrong. The two rules quoted below are real. The error was looking for a design in which the WORKERS share a mutable indegree table, finding that forbidden, and concluding the shape was impossible. Go's worker pool does not share that table either — it lives in the one coordinator goroutine, and workers are stateless pullers on a jobs channel that report on a results channel. Neither rule reaches that:
Task(T)is affine and non-storable (docs/spec/03-types.md:400), sohs := [spawn work(1), spawn work(2)]is still refused with "a task handle cannot be stored in a container or aggregate". But a pool is not N handles. It is one handle whose task fans out internally, which satisfies affinity by naming it individually.- A
parallel forbody still may not write a captured variable — `out[i-1] = i - 2
inside one is still refused with "parallel for cannot mutate captured variable 'out' in place". But no worker needs to. The indegree array is a local of the coordinator thread, mutated only by its owner, so the capture rule never applies to it. What a worker needs travels with the job:Job` carries each dependency's current hash, filled in by the coordinator.
Three probes on 2026-08-13, before any of this was written, each ran clean: four parallel for bodies all recving from one shared channel; two spawned workers sharing one jobs channel, splitting eight items 2+6 — uneven, so pulled on demand rather than statically partitioned; and a full coordinator loop over a diamond DAG with deps/indeg arrays, which finished in order with no deadlock.
What the mistake cost. A build tool that idled its pool. Under the wavefront, one long chain sitting inside a wide level held up every node behind it however free the workers were. tools/tycho-make/race.mk is the fixture that shows it: a 3-node instant chain beside three one-second sleepers at the same depth. The wavefront started the chain's second node at trace line 9, after all three sleepers had finished; the work queue starts it at line 6, about a second earlier, and tools/tycho-make/run.sh [8b] asserts that ordering rather than any duration. The entry also stood as a general claim about the language, which is the part worth withdrawing loudly: it named an "honest ceiling" that was not one.
The determinism claim survived intact. Completion order is a wider race now — the ready set is no longer bounded by a level — and the log is byte-identical anyway, at TYCHO_THREADS 1, 2 and 8 over six cold runs, because reassembly was never a property of the scheduler. The golden did not move: the transcript the wavefront produced is the transcript the work queue produces, which is exactly why the [8b] leg had to be an assertion in the runner and could not be a recorded one.
The one thing here that stands is that os.run is thread-safe in practice: 12 concurrent subprocesses returned the right exit code and the right captured stdout at three different pool widths (probed 2026-08-13). plan.md had this down as unverified with a fallback to internal actions; the fallback was not needed, and tycho-make runs real recipes. That mattered more under the work queue, not less — more subprocesses are now in flight at once.
28. ~~a struct field cannot name a type declared later in the same file~~ — FIXED 2026-08-13
Pinned-by: test -f tests/reject/struct_mutual_by_value.ty Pinned-by: make test
Functions in a package are order-free — tools/tycho-make/graph/graph.ty@order calls find_cycle, declared 30 lines below it. Types are not, and nothing in docs/spec/03-types.md says so:
$ ./tychoc probe/main.ty -o probe/x
probe/main.ty:3: error: unknown type 'E'; did you mean 'S'?
3 | e: E
The same for a struct field naming a later struct (did you mean 'A'?). The diagnostic is the ordinary unknown-type one, so the "did you mean" suggests the enclosing type — which is never the answer — and says nothing about ordering. The fix is to move the declaration up, which is what build/build.ty does. Cheap to live with, and cheap to improve: the name IS in the file, so the message could say "declared below; a type must be declared before it is used".
Closed by making types order-free instead, which is what Go and Odin both do at package scope. Improving the message would have documented a limit the standing default says should not exist.
A field's type is resolved as it is parsed, which is the whole cause; function bodies escape it because they resolve in a later pass. So parse_program pre-scans the token stream for the start of every top-level struct/enum/type/handle (src/tychoc.c@scan_type_decls), and a field naming one that has not been parsed yet parses that declaration on demand from its own sub-parser, then retries the lookup (src/tychoc.c@force_type_decl). The main loop steps over a declaration already forced this way. Source order is otherwise untouched, so no program that compiled before takes a different path: forcing only fires on a name that was an error the day before.
Three things the change had to get right, each pinned by a fixture:
- The knot the language cannot have.
struct A: b: Bwithstruct B: a: Anow *parses*, where before it died on the forward reference. Two structs cannot contain each other by value, so it must still be refused — and it is, by the containment DFS that already refused a direct self-reference (src/tychoc.c@emit_aggregate), with the sameinfinite type: A contains itself by valuemessage. Nothing new was needed; the DFS was already general.tests/reject/struct_mutual_by_value.typins it, message included. - Self-reference must keep working. A struct registers its own name before its fields, so
[Node]insideNoderesolves without forcing anything; the on-demand parse skips a declaration already in progress rather than recursing into it.tests/type_forward_ref.tycarries a recursive child list alongside the forward references. - A name declared nowhere is still unknown.
tests/reject/type_forward_unknown.ty.
The re-entrancy that on-demand parsing introduces was the real risk, not the lookup: parse_struct held a StructDef * across the field loop, and a nested declaration reallocs g_structs underneath it. Those pointers are now re-derived from an index after every parse_type call, in both the struct and enum parsers. That hazard predates this change — struct_instantiate could already realloc the table from inside parse_type — so the fix stands on its own.
The "did you mean" is left alone. It reads badly in the report above only because E existed and was not found; for a name that genuinely does not exist, suggesting the enclosing struct is a fair guess, since a struct may name itself in a field through [S].
Found by tools/tycho-snap, 2026-08-13 (head 56709b34)
A dogfood audit, chosen by measurement rather than taste: six corelib packages had ZERO consumers outside corelib/ — intern, log, sqlite, testing, toml, zip — and a package nobody calls is a package whose ergonomics nobody has tested. tools/tycho-snap is a real program against four of them: read a TOML manifest, walk a tree, archive it as a zip, and prove the archive by reading it back and checking every CRC against the bytes that went in. Every finding below was hit while writing it, in order.
29. ~~cli.parse_spec's schema names must be UNDASHED, and getting it wrong is silent~~ — FIXED 2026-08-13 (c46952de); see the banner below. The residue — unknown() is advisory — is not fixed.
Pinned-by: test -f corelib/cli/cli.ty Pinned-by: make corelib
The schema lists and cli.get's key take the option name without its leading --; the parser strips the dashes before matching (corelib/cli/cli.ty@parse_into). Writing them the way you type them at a shell — parse_spec(argv, ["--manifest"], ["--quiet"]) — compiles, parses, runs, and silently uses every default. Measured on the same argv:
dashed get=[DEFAULT] unknown=2
bare get=[real.toml] unknown=0
The tokens are not lost: they land in cli.unknown(c), which exists for exactly this. But nothing makes a caller read it, so the failure mode is a program that runs to completion on a configuration nobody asked for. This is the core:regex-class shape recorded at open-list item 12 for cli.has: the answer is not wrong, the question was silently a different one. tycho-snap now reads unknown() and missing() and exits 2, which is the workaround, not a fix.
FIXED 2026-08-13 by accepting both spellings. corelib/cli/cli.ty@undash strips a leading -- (or a single - on a short flag) from every schema entry in parse_into, and from the key get, has and flag are given. It cannot break an existing caller: an entry beginning with -- matched no token before this line existed. The same probe now reads
dashed get=[real.toml] unknown=0
bare get=[real.toml] unknown=0
~~What is NOT fixed is the general shape: unknown() is still advisory, so a typo'd option (--maifest) still runs the program on defaults unless the caller reads it.~~ **FIXED 2026-08-14 by a shape that cannot be ignored: corelib/cli/cli.ty@parse_checked returns Result(Cli, CliErr) and refuses to hand back a Cli carrying anything in unknown or missing. Not a die -- a typo'd option is INPUT, and corelib dies only for a use-after-free (corelib/pool/pool.ty@get) -- but a Result has to be matched.
Measured before writing it: 6 programs call parse_spec and 5 already read unknown() by hand. The sixth, tools/tycho-kvsrv, did not — --pot 9000 started the server on the default port with nothing said. It now reports unknown option --pot, and --port with nothing after it reports --port needs a value.**
30. ~~[string] + [string] is refused with a reason that is false~~ — FIXED 2026-08-13 (c46952de), both sites; see the banner below
Pinned-by: make test
out = out + [s] on a [string] — the append every Go and Python habit reaches for — gives:
error: `+` is not defined element-wise on [string], because `+` is not defined on string
The clause after the comma is untrue, and one line of the same program disproves it: "x" + "y" prints xy. Probed at 56709b34, [1, 2] + [3, 4] prints 4,6, so + on arrays is element-wise by design and the real rule is that there is no element-wise + for string elements. The message was built from the element type at two sites, where arr_elem(lt) was spelled into a sentence that reads as a claim about the language.
FIXED 2026-08-13, both sites (src/tychoc.c:1263@element-wise and src/tychoc.c:1263@element-wise, anchored per this file's header rule). The false clause is gone and + now names the operation the caller actually wanted:
error: `+` is not defined element-wise on [string] -- there is no element-wise `+` for string (to APPEND one element, write `push(xs, x)`)
The user's actual mistake is not arithmetic at all — it is that append is push(xs, x). A message naming push would end the confusion in one line instead of sending the reader to check whether + really works on strings.
31. ~~A generic struct is constructed from its FIELDS, in field order, not from its type parameters~~ — RECORDED, not proposed: this is the ordinary struct-literal rule applied to a generic
intern.Interner($K, $V) is instantiated by writing its two field values — an empty [$V] and an empty [$K: int] — so a string -> int interner is:
ei := intern.Interner([]int, []string: int) # V's array first, K's map second
Reading the declaration struct Interner($K, $V) and writing Interner(string, int) gives error: expected an expression, pointing at string. The order is not the declaration's — it is whatever order the fields happen to sit in (nodes: [$V] before lookup: [$K: int]), so the two type parameters appear reversed. Nothing in the diagnostic connects the two.
Not proposed as a change: this is the ordinary struct-literal rule applied to a generic, and inference from the field values is what lets the type parameters go unwritten. Recorded because the first thing a caller reads is the ($K, $V) header, and it is a false friend.
32. ~~Mangled instantiation names reach the user~~ — RECORDED, cosmetic; one pass over type_name/callee spelling, not a phase
Two diagnostics from the same session, verbatim:
error: argument 1 of 'intern__count__intern__Interner__string__int__string__int' is not inout; remove the '&'
error: cannot compare intern__Handle with int
The first names a symbol the programmer never wrote and cannot find in any source file; the source spelling is intern.count. The second is closer but still prints intern__Handle for intern.Handle. Both messages are otherwise good — the first even names the fix. Purely cosmetic, and worth one pass over type_name/callee spelling at some point, not a phase of its own.
What did not go wrong, which is also data
core:zipis interoperable, and nothing in this tree had ever checked. Its output is read by Python'szipfile:testzip()returnsNone(every CRC verified by a second implementation), the member list matches, and one member's SHA-256 out of the archive equals the file on disk. An empty archive is a valid 22-byte end-of-central-directory record thatzipfilereads as[], rather than a zero-byte file or a crash.- Two runs are byte-identical once the walk sorts each directory level —
io.listorder isreaddir(3)order and a snapshot that inherits it is not a snapshot. That is the program's job, not the package's, andsort.ascmade it one line. core:toml,core:logandcore:interndid what their headers say on first reading, with no probe needed: dotted-pathget, aninoutlogger with four levels, and handle identity across repeated keys.- A directory is a package, including a scratch directory. Two unrelated
package mainfiles in one throwaway folder make the compiler pull the sibling into the build:tychoc: plus.ty is in packagemainbut has nopackagedeclaration. Correct, documented, and still a five-second stumble when the folder is a scratchpad rather than a project.
33. ~~_ is an ordinary variable, not a discard — and it reads like one~~ — the DIAGNOSTIC is fixed 2026-08-13; the language is unchanged and deliberately so
Pinned-by: test -f tests/reject/underscore_not_discard.ty Pinned-by: make test
Found writing tools/tycho-snap/run.sh's fixture, where a call's result was genuinely not wanted. Every spelling below was run at bf18a560:
_ = f(1) error: assignment to unknown variable '_'
_ = r(1) error: assignment to unknown variable '_' (Result, same)
_ := f(1) OK -- DECLARES a variable whose name is one underscore
_ = f(2) OK, now: it is an ordinary assignment to that variable
_ = r(3) error: cannot assign Result(int, E) to '_' of type int
println(str(_)) prints 2 -- the value is live and readable
for _ in [7, 8] OK, and the body can read `_`; it is the loop variable
match ...: _: pass OK -- but this `_` is the PATTERN wildcard, a different thing
f(4) OK -- a bare call statement drops the value, no name needed
The last error is the sharpest: _ has a type, inferred from whatever was first assigned to it, so a _ that was an int cannot later absorb a Result. Nothing about it is special-cased.
Two things make this worth an entry rather than a shrug. The wildcard _ in a match arm IS special, so the language uses the same character for a real wildcard in pattern position and for an ordinary identifier in expression position — the asymmetry is invisible at the call site. And the tree's own corpus reads as though the discard exists: corelib/test/io/main.ty:330 is _ := io.make_dir(sdir) followed at :487 by _ = io.remove(cf), which looks exactly like Go's blank identifier and is in fact a live variable holding the last Result assigned to it. A reader who copies that line into a file where _ was never declared gets "assignment to unknown variable", and a reader who copies it into one where _ holds a different type gets the type error instead.
FIXED 2026-08-13: the diagnostic says it, in both positions. No blank identifier was added — a real one is a language decision, not a papercut fix, and the spelling that works costs nothing: call the function as a statement.
_ = f(1) error: `_` is not a discard -- it is an ordinary variable, and this one
was never declared. To drop a result, call it as a statement
(`f(x)`); to keep it, name it (`x := f(x)`)
a, _ = two() error: `_` is not a discard -- it is an ordinary variable, and this one
was never declared. Name every element, or declare `_` once with
`:=` and reuse it
The two differ because the fixes differ: a tuple element cannot be called as a statement, so there every name has to exist. Pinned by tests/reject/underscore_not_discard.ty and tests/reject/underscore_tuple_not_discard.ty; make test went 683 -> 685. A _ that WAS declared still assigns in both positions, checked both ways, and an ordinary typo keeps its did-you-mean (conut = 2 still suggests count).
The original ask follows. What the entry asked for was "assignment to unknown variable '_'" (with a did you mean 'c'? suggesting an unrelated local, observed) is true and useless; "_ is not a discard here -- call f(x) as a statement to drop its result" would end it in one line, and is the same shape as the push hint added to the element-wise message in #30.
Found by tools/tycho-tally, 2026-08-13 (head b188feb2)
The second dogfood audit, against the last two corelib packages with no consumer outside corelib/: core:sqlite and core:testing. tools/tycho-tally is an expense ledger whose --selftest is 15 core:testing assertions over a real SQLite file — a test framework nothing tests being the sharper of the two subjects.
34. ~~The obvious parse_int is the fail-open one, and the safe one is longer~~ — RENAMED 2026-10-19: parse_int is strict; parse_int_prefix is the explicit lax form
Pinned-by: test -f corelib/test/strings/main.ty Pinned-by: make corelib Pinned-by: grep -q 'fn parse_int_prefix(s: string) -> int' corelib/strings/strings.ty Pinned-by: grep -q 'fn parse_int(s: string) -> Result(int, IntErr)' corelib/strings/strings.ty
strings.parse_int stops at the first non-digit and answers with what it got. Measured at b188feb2, beside its checked sibling:
[350] parse_int=350 parse_int_checked=Ok(350)
[35x] parse_int=35 parse_int_checked=Err(Garbage)
[x35] parse_int=0 parse_int_checked=Err(Garbage)
[] parse_int=0 parse_int_checked=Err(EmptyInput)
[9999999999999999999999] parse_int=0 parse_int_checked=Err(OutOfRange)
Status, 2026-10-19: the rename landed (Phase 6 of the
tycho-dupefriction phases). The table's names are historical:parse_int_prefixis the fail-open column andparse_intis theResultcolumn. Internal numeric-prefix grammars and pre-validated CLI sites useparse_int_prefixdeliberately; user-facing unvalidated sites were moved toparse_int.
A ledger that reads an amount with parse_int books 35x as 35 and abc as 0 — a free entry — and nothing anywhere says so. This is not the old entry 4 ("strings.parse_int fails open, so no format parser can use it", struck as ALREADY FIXED): that was closed by ADDING parse_int_checked, and the trap it names is still there, under the shorter and more inviting name. The package's own comment says the checked one exists because "server/main.ty each hand-rolled a strict parser for want of this".
Not proposed as a rename, but the call sites were audited, 2026-08-14. Of the 40 parse_int calls, exactly two were fed unvalidated input: tools/tycho-kvsrv (--port abc became port 0 — a kernel-chosen port — and --port 80x became 80) and examples/weblog (--top=9x meant 9; its < 1 guard caught abc but not that). Both now use parse_int_checked. The rest pass digits they produced themselves. So the harm was two programs, not the name.
parse_int's fail-open behaviour is documented on its own line and something in the tree may want it. What is worth recording is that the audit reached for the short name first, and that the two differ by a suffix rather than by anything a reader would notice at the call site — the same shape as #29, where the plausible spelling was the silent one.
What did not go wrong, which is also data
core:testingfails honestly, and this is now GATED. The whole risk of a test framework nobody tests is that it printsokunconditionally. Probed by changing one expected total in a copy of the program: it printedFAIL: coffee total folded by SUM (got 625, want 999), thenFAIL tycho-tally (1 of 15 checks failed), and exited 1. That control istools/tycho-tally/run.shleg [2] — it edits a COPY, never the tree, so the proof runs on everymake tally-check.core:sqlite's parameter binding holds against a hostile value.o'brien'); DROP TABLE entry; --inserted throughexec_paramsreads back as one row with its bytes intact, and the table is still there afterwards — three assertions in the suite, not a claim in a comment.sqlite.execon malformed SQL isErrwith a message, not a silentOk(0):THIS IS NOT SQLis asserted in the suite.- A
Dbhandle is an ordinary struct over aptr, soopen->closeis the whole lifecycle and nothing in the language stops a use-after-close. Not a finding — the package saysh: ptr # the sqlite3* handle; 0 = closed— but worth knowing before someone reaches fordefer-shaped reasoning that does not exist here. - The compiler's copy warning fires on a live
bytes/stringreused after an aggregate takes it.[hostile]in a query parameter list, withhostilestill live afterwards, warns and names the fix ("make this its last use"). Correct, and the message is the useful kind.
Found by tools/tycho-agg, 2026-08-13 (head 097f7e16)
The third dogfood audit, and the first chosen by a LANGUAGE FEATURE rather than a package — every corelib package had a consumer by then. tools/tycho-agg declares its own generics in pipe/ and instantiates them across a package boundary at its own Row type.
THE MEASUREMENT THAT PICKED IT WAS WRONG, and the review caught it the same day. This section first said "
$Tappeared in ZERO files undertools/,examples/,server/andbench/" and built the program's whole justification on it.$is an end-of-line anchor in a POSIX regex, sogrep "$T"searches for *end-of-line followed by T* and cannot match anything; the zero was an artifact of the instrument, not a property of the tree. A fixed-string count finds ~50 uses across six files —tools/tycho-flow/stage/stage.ty(21,struct Item($T)),tools/tycho-flow/graph/graph.ty(16,struct Plan($T)),tools/tycho-q,tools/prunner, andexamples/generics_tour.ty, which exists for exactly this subject. Writing generics outsidecorelib/was not new here. The findings below stand on their own — they are properties of the type system, not of who had written a generic first — and the lane's one defensible uniqueness is narrower: no otherrun.shgreps the emitted C for apkg__fn__typemangling.It is the same failure mode this file records against everything else: a proxy measured instead of the thing, and a green (here, a zero) believed because it was convenient.
grep -Fis what should have run.
35. ~~A where hashable(K) constraint does not admit K as a map key~~ — FIXED 2026-08-13: it does now, and the constraint is enforced at instantiation instead
Pinned-by: test -f tests/generic_map_key.ty Pinned-by: make test
Six lines, run at 097f7e16:
fn counts(xs: [$K]) -> int where hashable(K):
m := []K: int # the constraint says K is a legal map key
for x in xs:
m[x] = 1
return len(m)
error: map keys must be string, int (directly or through a newtype), a fieldless
enum, or a hashable struct/tuple/array
The constraint exists to say exactly what the message asks for. A generic that wants a map keyed by its own parameter therefore cannot build one, and the shape this forces is visible in the corelib itself: corelib/intern/intern.ty's Interner($K, $V) is constructed by the caller, where K is concrete, and every function there only ever receives it. tycho-agg hit the wall and took the same way out (tools/tycho-agg/pipe/pipe.ty@group_into groups INTO a tally the caller built, rather than returning one it made).
FIXED the same day. The DECLARED type [$K: $V] already routed a type parameter past the key check (the has_typaram test beside mapc_of); the empty map LITERAL did not, and called map_of on an unsubstituted parameter. It now takes the same route. The constraint is not weakened — it is checked where the type is known: distinct([1.5, 2.5]) reports 'distinct' instantiated with K = float, which does not satisfy \hashable(K)\, which is a better place for it than the declaration, because the message can name the type that was chosen. Pinned by tests/generic_map_key.ty (string, int and a struct key) and tests/reject/generic_map_key_unhashable.ty; make test went 685 -> 687.
36. ~~Two spellings a generic cannot use, and one it cannot infer~~ — all three resolved 2026-08-13: two spellings fixed, and the third was a FALSE CLAIM — the explicit type-argument form always existed; its diagnostic is what was missing
Pinned-by: test -f tests/generic_explicit_typearg.ty Pinned-by: make test
Hit in order while writing pipe/:
[]$Tis not an empty-array literal. *FIXED 2026-08-13: it is now.* The empty-literal parse site accepted every type-token EXCEPTTK_DOLLAR, so the literal fell through to the untyped marker and the$died on the next token.parse_typehad always handled$T.out := []$Tiserror: expected newlineat the$. Outside a generic[]stringand[]intare the ordinary spellings, so the analogy is the first thing a writer reaches for. The working form is a bareout := [], whose element type comes from a laterpush— which is what every corelib generic does (corelib/iter/iter.ty,corelib/arrays/arrays.ty).- A bare
[]needs an expected type. InTally([], [])it iserror: cannot type a bare [] here -- no expected type (write []T, or use it where the element type is known). The message's[]Tadvice is right and is the OTHER spelling: bareK, not$K, works in expression position —$is the declaration form. Nothing says so at the point of use. - A type parameter appearing only in the RETURN type cannot be inferred.
fn tally() -> Tally($K)compiles, and the callt := tally()fails one line later withargument 1 of bump is pipe__Tally__t28, expected pipe__Tally__string— an unbound type variable escaping into the next call's message. ~~There is no explicit-instantiation syntax to rescue it, so such a constructor cannot be written at all.~~
THAT LAST SENTENCE WAS WRONG, retracted 2026-08-13. The explicit type-argument form name$(T, …) is specified (§7.1, §7.5 — docs/spec/05-generics.md:39) and has always worked: tally$(string)() builds the tally and bump accepts it. I asserted an absence without grepping the spec I had open, which is the same failure as the $T count this section's preface retracts.
What was real is the DIAGNOSTIC, and it is fixed. The call that could not be inferred used to compile, leaving the unbound cell to surface one call later under a bind-vector name. instantiate_generic now checks for a parameter no argument bound and dies at that call:
error: no argument fixes 'empty''s type parameter $T -- it appears only in the
return type. Give it explicitly: empty$(<the type for $T>)(...)
Pinned both ways by tests/generic_explicit_typearg.ty (the form working at two instantiations) and tests/reject/generic_return_only_unbound.ty.
What did not go wrong, which is also data
- Cross-package generic instantiation works, at a type the corelib has never seen.
pipe.keep,pipe.to(two parameters,$T->$U),pipe.fold(three types in one signature) andpipe.group_intoall instantiate atmain's ownRow, with the function values written inline as lambdas at the call site.make agg-checkasserts the mangled instantiations appear in the emitted C, because the counts alone cannot show a generic ran. - The
required from herenote is the reason these were diagnosable at all. Every generic error above pointed at the instantiating call inmain.tyas well as the failing line inpipe/, which is what turned "an unbound type variable" into "this call did it". whereclauses compose with aninoutgeneric struct.bump(t: inout Tally($K), k: $K) where hashable(K)takes the tally by reference, mutates the map, and the caller's copy sees it — no aliasing surprise and no annotation beyond the one on the parameter.
Found by tools/tycho-tmpl, 2026-08-14 (head 08f50a5f)
The fourth dogfood audit. Subject picked by measurement, with grep -F this time: sink appears 18 times in the tree and all 18 are in tests/ — no program under tools/, examples/, server/ or bench/ uses it, and neither does corelib/. A parameter mode exercised only by its own fixtures. tools/tycho-tmpl is a line-oriented template renderer whose document builder (tools/tycho-tmpl/doc/) consumes at every step.
37. ~~sink cannot express a builder~~ — CLOSED 2026-08-18: deliberate, and PINNED
Pinned-by: test -f tests/reject/sink_builder_two_mentions.ty Pinned-by: make test
The rule and its reasoning are in the source (src/tychoc.c@sink_arg_into): rather than silently copy, require the move-vs-copy to be visible. The cost is real — the composition must be point-free and a value observed before it is consumed has to be recomputed — and it is the price of the rule, not a defect. tests/reject/sink_builder_two_mentions.ty pins the refusal now, so a change reddens instead of going quiet. The record of what the item said when open follows.
Four refusals, in the order a builder hits them. Each is the compiler's own message, at 08f50a5f:
- Accumulate in a loop —
d = add(d, s)insidefor: *"'d' is consumed by asinkparameter inside a loop, where the next iteration would consume it again; no named variable can be adopted in a loop, a copy made in the loop included."* - Collect then consume —
push(lines, s)in a loop, thenof(lines): *"'lines' … is mentioned 2 times in this function: a sink argument must be the variable's ONLY mention."* - Create, grow, consume —
f := empty(),f = add(f, …),join(d, f): three mentions, same refusal, and this one is outside any loop. - Count before consuming —
n := len(lines)thenof(lines): the count is the second mention.
So the only expressible shape is point-free. Nothing may be named:
doc.render(doc.join(doc.of(expand_all(text, keys, vals)),
doc.add(doc.empty(), "-- rendered " + str(n) + " line(s)")),
sep)
and a value that must be *observed* before it is consumed has to be recomputed — tycho-tmpl calls expand_all twice, once to count and once to adopt, which is the honest cost of rule 4.
The rule is deliberate and the reasoning is in the source: src/tychoc.c@sink_arg_into — "Rather than silently copy, require the move-vs-copy to be visible (Hylo-style)", and one textual mention "is how the compiler proves the consume is the last use on every path, with no dataflow analysis". Nothing here argues with that. What is recorded is the CONSEQUENCE, which no fixture showed because a fixture consumes once and stops: a consuming API cannot have a builder, and its callers must be written point-free.
THE SPEC WAS WRONG, and is corrected. docs/spec/11-functions.md §15.2 said a copy "is made only where value semantics require independence (the variable is read again after the call, is used inside a loop, or is captured by a closure)". No implementation ever made that loop copy — the call is refused. The paragraph now states the implemented rule and says what it used to claim.
What did not go wrong, which is also data
- Every one of the four messages named the variable, the rule AND the fix. Rule 2's even lists the disqualifying forms (
len(lines),lines[0],lines[0] = …) and says "even before this line", which is what turned a puzzling refusal into a five-second edit. cli.parse_checkedearned its keep on its first outside caller. This program used it from the start and--sett x=1was refused by name — the shape FRICTION #29 asked for, working in a program written after it.- A
sinkon a plain array works exactly as specified.doc.of(xs: sink [string])adopts a call's result with no copy; only NAMED values are the problem.
Found by tools/tycho-stat, 2026-08-14 (head 6037a235)
All three are FIXED, with the compiler's own message quoted as it stood before the fix. The program that found them is gated by make stat-check. tests/pkg/variadic_qual/ pins the working forms; its two reject siblings and tests/reject/variadic_empty_untyped.ty pin the refusals.
38. ~~A variadic called through a package qualifier did not pack~~ — FIXED 2026-08-14
Pinned-by: make test
vp.sum(1, 2, 3) # error: 'vp__sum' takes 1 argument(s), got 3
local_sum(1, 2, 3) # fine -- the same declaration, called unqualified
The fold that turns trailing arguments into one array ran under if (!e->qual && !e->lhs), so a qualified call reached the arity check UNPACKED and was measured against the one declared parameter. The ...T was not mis-typed or mis-inferred; it was never applied. Dropping !e->qual is the whole fix — the lookup name needs no prefixing, because e->sval is already the mangled vp__sum at that point (a first attempt that prefixed it searched vp__vp__sum and failed identically, which is what the probe caught).
This is a cross-package defect, which is why three files of fixtures never saw it: every variadic call in the tree was same-package.
39. ~~Explicit type arguments did not parse on a qualified name~~ — FIXED 2026-08-14
Pinned-by: make test
vp.ident$(int)(5) # error: expected ')' <-- pointing at the '$'
Nothing to do with variadics: the qualified-call arm tested only for ( after the name, so a $ fell through to a FIELD ACCESS and the spelling was a parse error in every form — one type argument or several, with value arguments or without. The bare form ident$(int)(5) has always worked, so the two spellings of the same call disagreed. The grammar named IDENT in that production and was therefore *accurate*; fixing the parser is a language change, and §3/§4, Appendix A, §7.5 and §15.3 all moved with it.
40. ~~An empty generic variadic refused the type it was handed~~ — FIXED 2026-08-14
Pinned-by: make stat-check
count$(int)() # error: cannot infer the element type of an empty variadic
# call to generic 'count'; pass at least one argument
The diagnostic was right that there was nothing to infer FROM and wrong that this left no answer: the caller had just named the type outright. The packing site never consulted e->ntypeargs, so the one spelling that could have supplied the element type was the one it rejected. It now binds the element from the explicit list in declaration order — the same rule instantiate_generic uses — and the message, for the case where nothing was named, points at the cure: *"…pass at least one argument, or name the type: count$(<type>)()"*.
What did not go wrong, which is also data
- Spread, fixed-then-variadic, and generic variadics all worked once the call packed.
vp.sum(arr...),vp.tagged("n", 7, 8)andvp.count("a","b","c")needed no further change — #38 was one missing fold, not a broken feature. zero$(T)underwhere defaultable(T), numeric(T)worked first time, which is the feature the package was written to exercise. The constraint list is comma-separated;andis not a spelling the grammar has.
Found by tools/tycho-ledger, 2026-08-14 (head d0e72f6e)
Sixth audit, same method: measure what the tree does not exercise, then write the first real consumer. type X = U is declared in 46 files and 43 are under tests/. The three genuine consumers — tools/tycho-vm/main.ty, corelib/intern/intern.ty, corelib/pool/pool.ty — each declare a newtype over int and use it INSIDE their own package, and exactly one file in the tree ever named a foreign package's newtype (examples/corelib/pool/main.ty:12). The cross-package surface was effectively untested.
It held. What broke was how the compiler TALKS about it, twice. Both are FIXED; tests/pkg/newtype_qual/, tests/reject/pkg/newtype_qual_mix/ and tests/reject/map_for_in.ty pin them, and the program is gated by make ledger-check.
41. ~~Every imported type and callee printed its MANGLED name~~ — FIXED 2026-08-14
Pinned-by: make test
declared type int but value is pool__Handle # the user wrote pool.Handle
argument 2 of 'money__add' is int, expected money__Cents
type_name returned the stored name verbatim, and a nominal type from another package is STORED mangled. So every diagnostic about a corelib type — not just a newtype: struct, enum and handle alike — named something the reader cannot type and cannot grep for. A same-package type was unaffected (got Pc, Slot), which is why the tree never noticed: the three real newtype consumers all stay inside one package.
Fixed centrally in type_name plus the die_at sites that quote a callee, via one helper that rewrites the first __ to . only when the prefix names an imported package — so a local type containing __ is left alone. Zero fixtures and zero goldens pinned a mangled name (measured before the change), and make test was 693/0 after it.
42. ~~for k in m reported an index the user never wrote~~ — FIXED 2026-08-14
Pinned-by: make test
for k in m: # error: map key must be string, got int
A map is not directly iterable; keys(m) is the way (§18.6). But for x in COLL desugars at parse time into an indexed loop, so the failure surfaced as a KEY TYPE error about m[_fi0] — an expression the user never wrote, naming neither the loop nor the cure. The synthetic index now carries a forin marker and the message says what to do:
a map is not directly iterable --
for k in mindexes it by position. Loop overkeys(m)instead:for k in keys(m):(the keys come back as X, wrapped)
A genuine wrong index still gets the precise old message (m[0] on a string-keyed map: *"map key must be string, got int"*), which is the negative control — the new arm must not swallow it.
Writing that guard is also where this audit's one self-inflicted bug lived: the new branch went in without braces, so the original
die_atfell OUTSIDEif (kt != wantk)and every map index died withgot Xequal towant X. Caught by reading the probe output, beforemake test.
What did not go wrong, which is also data
- The whole cross-package newtype surface worked on the first build: arithmetic and ordering in the consumer, a map KEYED by a foreign newtype over string,
keys()handing the keys back wrapped, arrays of a foreign newtype,to_int/to_str/to_under, and a struct field typed as one. sort.ascinstantiated at a foreign newtype, sowhere comparable(T)admits a newtype over string across two package boundaries at once.- All nine distinctness violations were refused, including a bare
stringused to index anAccount-keyed map. The type system's half of the feature is sound; only its vocabulary was wrong.
Found by tools/tycho-fh, 2026-08-14 (head ff7894f3)
Seventh audit, and the last feature whose only caller was its own fixture: handle Name: was declared in exactly 10 files, all under tests/ — nine rejects and tests/ffi/main.ty. Nothing under tools/, examples/, server/, bench/ or corelib/ declared one. Writing the first consumer found a double free.
Both are FIXED as hard errors, on the user's instruction ("hard error not a warning") after being told the copy fix refuses programs that compile today — blast radius nil, since no program outside tests/ declares a handle.
43. ~~A handle could be COPIED, and the copy double-freed~~ — FIXED 2026-08-14
Pinned-by: make fh-check
f := fh_open("/etc/hostname", "r")
g := f # accepted
free(): double free detected in tcache 2
Both names got a scope-exit destructor call on one pointer. This is memory corruption in a language whose claim is memory safety without a GC, reached by two lines of ordinary-looking code.
Reassignment was already guarded (g = f → *"a handle variable cannot be reassigned"*), and so was the exact sibling one type up: a task, two lines earlier in the same switch, refuses u := t with *"a task handle cannot be copied or re-bound"*. Only the handle DECL path was open. The fix mirrors the task rule — a handle-typed declaration is legal only when its RHS is a call, which is the one thing that can produce a handle (an extern opener).
44. ~~A BARE handle as a struct field was accepted~~ — FIXED 2026-08-14
Pinned-by: test -f tests/reject/affine_handle_container_type.ty Pinned-by: make test
struct S:
f: File # accepted; every other aggregate refuses it
tests/reject/affine_handle_container_type.ty covers items: [R] — an ARRAY of handles in a struct field — and passes because the array intern helper refuses it. Every affine refusal in the compiler lives in such a helper (arr_of, opt_of, res_of, tuple, map). A handle that *is* the field type passes through no helper at all, so nothing checked it. The check now runs where the field is registered, and reports the field's own line.
This is the shape of both bugs: the guard is on the type CONSTRUCTORS, so every path that builds no new type slipped by. Worth remembering the next time an affine rule is added.
What did not go wrong, which is also data
- RAII itself is sound. 20000 opens, each in its own scope:
live 0,opens == closes, and a re-read checksum of exactly 20000 x 19 — so no fd leaked and no open silently began failing. - A borrow is still a borrow. Passing a handle twice in one expression leaves exactly one live owner, and the callee does not free it (spec §25).
- Four affine rules already held: returned from a Tycho fn, in an array, in an
Option, andclose()on a call result are each refused by name.
45. ~~A channel can be copied too, and it makes the compiler's own warning lie~~ — FIXED 2026-08-14
Pinned-by: test -f tests/reject/affine_chan_copy.ty Pinned-by: make test
c := channel(int, 2)
e := c # accepted
send(e, 41)
println("got=" + str(recv(c))) # prints Some(41)
warning: nothing ever sends on channel 'c', so a receive on it parks forever
The send happens through the alias, so the warning is false. No double free comes of it — the free is keyed to the creating declaration — so this is an analysis defect rather than a memory-safety one. It was the same missing decl-path guard as #43, in the channel's switch arm rather than the handle's, and it is closed the same way: a channel-typed declaration is legal only from channel(...) itself. The spec already said so and nothing enforced it — channel(T, cap) is "legal only as the direct right-hand side of a declaration" (docs/spec/13-concurrency.md:176). Passing a channel as an argument is untouched, which is how a consumer still receives one. Locked by tests/reject/affine_chan_copy.ty.
Three members of one family now refuse the same shape at the same place — a task, a typed handle, a channel — and each of the three was open on the DECL path while reassignment was already guarded. That is the pattern worth remembering rather than the three fixes.
49. ~~sink and inout on an affine type were accepted and silently ignored~~ — FIXED 2026-08-14
Pinned-by: make test
Carried for a day as an open question — the spec said passing a handle BORROWS but said nothing about sink, so "intended" and "unguarded" looked alike. Measuring it answered it. With a counting C shim, take(h: sink File) left the handle live and unclosed at return (live=1 closes=0): the callee borrowed exactly like a default parameter. The declared mode did nothing at all.
Refused rather than implemented, at the declaration. A consuming callee would have to free at ITS scope exit while the destructor is emitted at the owner's — which is precisely the double free of #43. inout goes with it: there is no copy-back for a value with one owner. The rule covers all three affine types, and §25 now states it.
A mode that reads as ownership and delivers a borrow is worse than no mode: it is a promise in the signature that nothing keeps.
50. ~~The spec claimed the sink consume rule applied to every type~~ — SPEC CORRECTED 2026-08-14
Found by probing #49's neighbours. sink on a NON-HEAP type is accepted and enforces nothing: f(n: sink int) then reading n compiles and prints, where the same shape on a string or [int] is refused. §15.2 stated the rule with no qualification.
The compiler is right and the spec was wrong, which is the reverse of the first three findings in this section and worth recording as such. Two existing fixtures — tests/reject/sink_arg_scalar.ty and sink_arg_newtype.ty — use sink int and sink Id as SETUP to test argument checking, so refusing sink on a scalar would have left both passing for the wrong reason: they would be rejected at the declaration and never reach the call-argument check they exist to pin. That is the vacuous-test disease, and nearly shipping it is the reason this entry exists.
The boundary is type_is_heap, measured: enforced for string, [T], a struct with any heap field and a newtype over one; inert for int, float, bool, an all-scalar struct and a newtype over a scalar. §15.2 now carries that table and the consequence a reader cannot see from a signature — fn take(p: sink Point) enforces nothing over Point{int,int} and starts enforcing it on every caller the day Point gains a string. tests/sink_scalar_noop.ty prints the proof.
Found by tools/tycho-grid, 2026-08-14 (head 8c06eb8a)
Eighth audit, three features at once — each had exactly one real consumer in the tree, measured 2026-08-14:
| feature | sole consumer before this |
|---|---|
subscript | tools/tycho-sim/world/world.ty |
bounded[N]T | tools/tycho-vm/main.ty |
# deprecated: | corelib/sort/sort.ty |
subscript and bounded[N]T came through clean — every rule in their spec sections holds, including the four capacity rejections, the element-type restriction, the runtime overflow abort, and all five subscript declaration rules. The deprecation MARKER had a defect in each direction.
46. ~~Prose that merely mentioned the marker deprecated the function below it~~ — FIXED 2026-08-14
Pinned-by: test -f tests/warn/deprecated.ty Pinned-by: make test
# this replaces the old deprecated: thing we removed
fn ordinary(x: int) -> int:
warning: `ordinary` is deprecated: thing we removed
dnote_scan walked the comment looking for deprecated: anywhere in the line, so any sentence containing the word marked the next fn, and every caller got a warning nobody wrote — pointing at a function that was never deprecated, with a message that is the tail of an unrelated sentence.
This was already latent in the tree: tests/warn/deprecated.ty:1 is a sentence containing the marker, one line away from a fn. The marker must now OPEN the comment (#, optional spaces, then deprecated:), which is the form both real users already use.
47. ~~A deprecated function taken as a VALUE warned nowhere~~ — FIXED 2026-08-14
Pinned-by: make grid-check
f := stale # no warning
println(str(f(1))) # and none here either -- the call names `f`
The warning fires at call sites, keyed on the callee's name; a call through a function value has no such name, so one binding laundered the whole policy. The warning now also fires where the value is TAKEN, which is the last point the name is visible. Locked by tests/warn/deprecated_edges.err, which carries exactly one warning — on the binding line — and none for the prose case above it.
48. A subscript cannot read two fields of its receiver, so 2-D indexing on a flat array is inexpressible — RECORDED, not open
Pinned-by: make grid-check
Not fixed — recorded, because the fix is a judgement call about double evaluation rather than a defect.
subscript at(g: Grid, r: int, c: int) -> inout int:
yield &g.cells[r * g.w + c]
error: subscript parameter 'g' is used more than once in the yielded place
The rule exists "so no argument is double-evaluated when substituted", and for r and c that is exactly right — either could be a call with side effects. But the RECEIVER is mentioned twice here only because the projection reads two FIELDS of it, and it is substituted as a place, not re-evaluated as an expression. The rule is stricter than its own rationale, and what it rules out is the single most obvious use of a subscript: indexing a flat array by row and column.
The way out is a nested array (yield &g.rows[r][c]), which mentions the receiver once and is what tools/tycho-grid uses — at the cost of the flat layout the feature would otherwise let you hide. Lifting the rule for the receiver alone would need care: a call-site receiver can itself be an arbitrary expression (make_grid().at(1, 2)), which would then be evaluated twice. That is a decision, not a bug fix, so it is written down rather than guessed at. tools/tycho-grid/run.sh leg [5b] pins the current behaviour, and says so.
Found by probing GENERIC INSTANTIATION, 2026-08-14 (head 81588f75)
Not an audit of one feature but of one seam: a rule enforced on the TEMPLATE and never re-checked on the INSTANCE. Four hits, all the same shape. The census that pointed here — function values had one real consumer, and no fixture anywhere combined bounded[N]T with a generic.
51. A generic struct with a [K: fn($T)->$T] field emitted an undefined FnC0 — FIXED
Pinned-by: make test
The map sibling of the array bug tools/tycho-flow/graph/graph.ty records as fixed. The array body loop skips a typaram'd composite; the map body loop never had that skip, so the dead template map named an FnC<id> that was deliberately not emitted and cc rejected the program. Every other map loop already had the guard. tests/generic_fn_map_field.ty.
52. bounded[N]T in a generic struct silently became [N]T — FIXED
Pinned-by: make test
subst_type rebuilt every instantiated array through fixarr_of, which hardcodes bnd=0, so the SIZE survived and the capacity RULE did not. The declaration said bounded and the program got a fixed array: passing a real bounded value type-errored, while a plain [N]T was accepted with nothing on stderr. It hit a concrete element type too (bounded[3]int in a generic struct), which is what proves it was the array rebuild rather than the substitution of $T. tests/generic_bounded_field.ty, tests/abort/generic_bounded_overflow.ty for the trap a golden cannot show, and tests/reject/generic_bounded_field_degraded.ty for the silent half — the two positive fixtures pass real bounded values, so they fail loudly and cannot catch it.
53. A generic field or payload bound at an affine type escaped the ban — FIXED
Pinned-by: make test
struct Box($T): c: $T instantiated at Channel(int) compiled and ran: CC-4's scan runs over the aggregates that exist when resolve_program runs, and an instance is stamped out later. Then b2 := b copied the struct and gave two owners one channel — a send through one was received through the other. The enum payload had the identical hole, and once a struct held a channel [b, b] put it in an array, so the whole containment ban fell. A Task has no type syntax at all, so a $T binding is the only way one can reach a field, which makes instantiation the only place it could ever be caught. Three reject fixtures. Every other storable position (array, map, Option, tuple, and a generic fn returning Option($T)) was already refused by a separate value-level guard.
54. sink $T bound to an affine type escaped the rule the parse site names — FIXED
Pinned-by: make test
Not a memory-safety bug today: measured against the counting shim in tools/tycho-fh/fh.c, sink $T at a handle gives opens=1 closes=1 live=0, identical to a plain borrow — sink on such a type currently enforces nothing, so nothing double-frees. inout $T is caught by a value-level guard; sink is not. It was an inconsistency in a rule the parse site states by name, and would become a real double free the day sink starts consuming affine types, so it is closed at the instance signature rather than left to that day. tests/reject/generic_sink_affine.ty.
55. A generic returning $T at a handle DOUBLE FREED — FIXED
Pinned-by: make test
Found by finishing the sweep of #51-54 rather than stopping at four: every declaration-time type rule, re-probed through $T. fn ident(x: $T) -> $T at a handle compiled, because the ban on returning one is checked against the WRITTEN return type, which is $T. g := ident(f) then freed the same pointer twice -- the callee at its scope exit, the caller on the copy -- and glibc aborted with double free detected in tcache 2, exit 134, against the counters in tools/tycho-fh/fh.c. A channel and a task were already refused out of the same generic by their own guards; only the handle was open, the same asymmetry as #54. tests/reject/generic_ret_handle.ty.
The rest of that sweep came back clean, and the negative results are the point: soa element, map-key hashability, the bounded element ban, recursive-by-value (caught on the instance, Node__int contains itself by value), and handle containment in a struct, enum, Option or array are all re-checked when the type arrives through $T. [$N]T in a struct field stays unreachable that way -- the type cannot be named at a call site to infer it.
56. ~~decimal.from_str fails open to a WRONG NUMBER, not to zero~~ — CLOSED 2026-08-18: the lax name is deprecated
Pinned-by: test -f corelib/test/decimal/main.ty Pinned-by: make corelib
from_str is marked # deprecated: and every call site now warns at compile time, naming from_str_checked and quoting the failure ("12.5x" returns 1.25). Behaviour is unchanged, so no program breaks; the silence is what was removed. The parse body moved to a private parse_unchecked so the checked path does not warn on its own delegation. All three tools/tycho-q sites and the worked example moved to the checked call; corelib/test/decimal keeps the lax one deliberately, since testing it is the point. The record of what the item said when open follows.
Found by sweeping every corelib parse function for FRICTION #4's shape after closing it. core:decimal is the money type — the package header calls itself "the right type for money and any base-10-exact arithmetic" — and its only constructor from text failed open worse than parse_int does. parse_int fails to 0, which is at least obviously a default. from_str returns a plausible wrong amount, because it splits on the point and lets bignum.from_str skip non-digits while the SCALE still counts them as digit positions:
"1.5x" -> coefficient 15, scale 2 = 0.15
"1.2.3" -> coefficient 12, scale 3 = 0.012
"1,5" -> 1 (the comma-decimal spelling, silently)
" 1.5" -> 0.0
Nothing in the tree was wrong because of it — tools/tycho-q guards both of its data paths with a decimal.to_str(d) == cell round trip, deliberately, and its third call site takes an already-shape-checked lexer token. The gap was the missing API: core:strings has parse_int_checked, and core:decimal had a Result-returning div with a DivErr, so the convention was present in the package and from_str was the outlier.
from_str_checked(s) -> Result(Decimal, DecErr) accepts exactly [-|+]digits[.digits] and nothing else; from_str is unchanged and still lax, so no caller moves. 17 inputs are asserted in corelib/test/decimal/main.ty, including the two wrong numbers above against the lax answer, so a regression in either direction moves that line. Arbitrary precision survives the check: a 20-digit coefficient round-trips exactly.
57. core:http cannot be pointed at a private CA — FIXED 2026-08-15
Pinned-by: make http-verify
Found 2026-08-15 while building scripts/tls_verify.sh, which proves core:tls verifies certificates. The same three-way check was written for core:http and then removed, because its positive control cannot be made to pass.
http_shim.c sets no CURLOPT_SSL_VERIFY*, so libcurl's verifying defaults apply and HTTPS is checked. But there is no way to add a CA:
CURL_CA_BUNDLEis read by the curl tool, not by libcurl.SSL_CERT_FILEis not honoured by this build (libcurl 8.21). Measured against a localopenssl s_server: an untrusted CA and the correct CA both givestatus 0.- The shim exposes no
CURLOPT_CAINFO.
Two consequences, and the second is why this is filed rather than shrugged at:
- A program cannot talk to an internal service with a private CA. The only workaround is installing the CA into the system store, which is a machine-wide change to make one program work.
core:http's verification is therefore ungated. ACURLOPT_SSL_VERIFYPEER, 0Ladded while debugging would pass every lane in this tree — the same holetls-verifyjust closed forcore:tls, still open here. Not because the check is hard, but because without a leg that must SUCCEED, "the untrusted server was refused" is indistinguishable from "nothing connected at all".
The fix is an API decision, not a patch: expose a CA path (an option on get, or an honoured environment variable) so a caller can trust a private CA, at which point the gate follows immediately from tls_verify.sh's existing shape.
Fixed the same day, by the environment variable rather than a new argument. http_shim.c reads SSL_CERT_FILE into CURLOPT_CAINFO and SSL_CERT_DIR into CURLOPT_CAPATH — the two core:tls already obeys through OpenSSL's own defaults, so the two HTTPS clients in this tree stop trusting different stores from the same environment. No signature moved and no caller changed.
They redirect trust and cannot disable it: no CURLOPT_SSL_VERIFY* is set anywhere in that file, so libcurl's verifying defaults stand whatever the environment says, and an unreadable path fails closed. Empty is treated as unset, so SSL_CERT_FILE= cannot degrade into a request with no CA at all.
make http-verify (scripts/http_verify.sh) is the lane this unlocks, and the measurement is worth keeping in both directions. Before, run against the unfixed shim: [2] and [2b] both status 0 — the positive control was unreachable exactly as this entry said. After: [1] 0, [2] 200, [2b] 200, [3] 0.
The run before the fix also settled something this entry could only assume. Its control leg [C] — a probe built against a COPY of corelib with SSL_VERIFYPEER/VERIFYHOST off — returned 200 against the same untrusted server. So core:http was verifying correctly all along; what was missing was any way to prove it. That is the distinction worth carrying: an ungated property is not a broken one, it is one that can break silently later.
Four negative controls, each reddening exactly one leg and nothing else:
| break | leg that reddens |
|---|---|
CURLOPT_SSL_VERIFYPEER, 0L | [1] alone — the untrusted chain is ACCEPTED |
CURLOPT_SSL_VERIFYHOST, 0L | [3] alone — a valid cert for another name is accepted |
the CAINFO line deleted | [2] alone — [2b] still 200 |
the CAPATH line deleted | [2b] alone — [2] still 200 |
The last two are the ones that matter for honesty: they prove SSL_CERT_FILE and SSL_CERT_DIR are two code paths rather than one option documented twice.
A note on the controls themselves, since it nearly produced a false result. The first control script restored the shim with git checkout -- between runs. That restores HEAD, which did not carry the still-uncommitted fix, so three of the four controls silently ran against the unfixed shim and every one of them "reddened" for the wrong reason. It looked like a clean result. Restoring from a copy of the working-tree file, and asserting the backup contains the fix before scoring anything, is what the script does now.
58. uuid.v4 looks like 122 random bits and carries at most 32 — documented 2026-08-15
Pinned-by: grep -q 'NOT unguessable' corelib/uuid/uuid.ty && grep -q 'not unguessable' docs/reference/corelib.md
core:uuid produces RFC 4122 version-4 UUIDs, and the whole point of v4 is that it is the random one. The source is core:rand's xorshift32, whose state is 32 bits. So the reachable set of UUID streams is 2^32, not 2^122, and the whole seed space is enumerable. Measured: the same seed reproduces the same sequence exactly, across processes.
That is correct behaviour for the generator -- rand's own header says "NOT cryptographic" in its first line, and it is right to be deterministic. The gap was that uuid never repeated it, and neither did the corelib catalogue, which described them as "random version-4 UUIDs". A reader who knows what v4 means will assume unguessable, and the API gives them no reason not to.
Both now say so and point at crypto.random_hex, which is RAND_bytes-backed.
Half of that was false until 2026-08-22, re-scored by reading. The catalogue said it (
docs/reference/corelib.md:251); the PACKAGE did not —corelib/uuid/uuid.ty@v4's own comment read "A random version-4 UUID, threading the core:rand state via inout", with no warning and no pointer, and that is the line a caller lands on when they jump to the definition. Carrying the warning on one surface and not the other is the same failure this entry is about, one level up. Now on both.
tycho-rsa (deleted 2026-09-04) was checked and was fine -- it seeded from a fixed constant on purpose and said so in its header, being a teaching implementation. (Deleted because pure-Tycho RSA cannot be constant time.)
Not fixed, deliberately: there is still no CSPRNG-backed UUID. Adding one is an API decision (a v4_secure taking bytes, or a uuid that depends on core:crypto and inherits its OpenSSL dependency), not a patch, and it would put a hard OpenSSL requirement on a package that currently has none. Documenting the limit is the honest interim; the decision is still open.
59. A strict check downstream of a lenient parse never runs — FIXED 2026-08-15
Pinned-by: test -f corelib/httpd/httpd.ty Pinned-by: make corelib
server/main.ty@bad_len already refused a Content-Length that is not a plain decimal, and its comment already named request smuggling as the reason. It answered 400 for -1. It did nothing at all for 4x.
The refusal was correct and unreachable. corelib/httpd/httpd.ty@to_uint reads leading digits and stops silently -- parse_int's fail-open shape (FRICTION #34) reimplemented locally -- and that lenient value is what the READ LOOP uses to decide how many body bytes to wait for. 4x framed a 4-byte body, the bytes never came, and the strict check sat downstream of a read that could not finish. The connection held a worker until the 15 s deadline instead of being refused in microseconds.
The general shape is worth more than the instance: a validator placed after a blocking parse of the same field is not a validator. The lenient parse decides whether control ever reaches it.
Framing is now strict at the point that blocks, and three ambiguous shapes answer 400 promptly: a non-decimal length, two Content-Lengths that disagree (httpd.content_length_conflict), and Content-Length alongside Transfer-Encoding (RFC 7230 3.3.3). Two controls sit beside them so the fix cannot become "refuse anything with two headers": a duplicate Content-Length that AGREES is not a conflict, and a lone Transfer-Encoding is not ambiguous. Both still reach 405.
Impact was bounded before the fix and it is worth being accurate about: this server answers no method that takes a body, so nothing was smuggled INTO an application. What it cost was a worker per malformed request for the full idle deadline -- the same resource-holding shape as the slowloris already recorded in corelib/httpd/httpd.ty's header.
The class was then swept, and it is bounded. Grepping parse_int( would never have found this -- to_uint does not call it -- so the search has to be for the SHAPE: a digit loop that returns its accumulator on the first non-digit. Exactly one such function exists in corelib/ and server/ (httpd.to_uint), and its one dangerous use site is the one fixed here. Every lenient parse_int call site on untrusted input was checked by hand and each already guards: tycho-ledger validates every byte before parsing and cites #34 while doing it, tycho-stat refuses a non-numeric field by name, tycho-tally records the measured parse_int("35x") == 35 in a comment beside its own check. The remaining call sites take CLI arguments, not untrusted input.
60. markdown.render escaped the text and not the SCHEME — FIXED 2026-08-15
Pinned-by: test -f corelib/test/markdown/main.ty Pinned-by: make corelib
core:markdown's header said "all text is HTML-escaped; only the constructs above emit tags", which reads as a promise that the output is safe to serve. It escapes &, <, > and " -- so an attribute cannot be broken out of -- and then put the link target straight into href with no check on what kind of URL it was.
Measured 2026-08-15, all three emitted verbatim and clickable:
[x](javascript:alert`1`) -> <a href="javascript:alert`1`">
[x](data:text/html;base64,...) -> <a href="data:text/html;base64,...">
[x](vbscript:msgbox) -> <a href="vbscript:msgbox">
Escaping and scheme-checking are different jobs and the header conflated them. Anyone rendering untrusted markdown with this package had stored XSS.
Now allowlisted: no scheme at all (relative, absolute-path, protocol-relative), or http / https / mailto. A colon counts as a scheme separator only before any /, ? or #, so a/b:c stays a relative path. The comparison is case-folded -- JaVaScRiPt: was the obvious way past a naive check. Anything else fails soft to the link text, matching what this module already does with an unmatched delimiter.
Nothing in the tree rendered a rejected scheme: make corelib, make corelib-examples and make weblog webserver were all green BEFORE the golden was re-recorded, which is what says the change is behaviour-preserving for real content rather than merely re-blessed. The new cases assert both directions -- the three dangerous schemes unlinked AND seven safe forms unchanged -- because "drop every link" would pass a one-sided test. Reverting safe_url to return true reddens the golden.
61. parse(stringify(rows)) == rows was false for one shape — FIXED 2026-08-15
Pinned-by: make format-diff
corelib/csv/csv.ty@stringify states that identity in its own doc comment. It held for 413 of 414 differentialed row-sets and failed for exactly one: a row that is a SINGLE EMPTY FIELD.
Unquoted, [[""]] writes a bare newline, and a bare line parses back as a row with NO fields. One cell in, zero cells out. Python's csv module writes "" for the same input, and for the same reason -- it is the only way to tell "one empty field" from "an empty row" in the format.
Found by differentialing csv.stringify against Python's csv.reader over 14 hostile edge cases and 400 generated row-sets drawn from an alphabet of comma, quote, newline, tab, space, = and a non-ASCII byte. 14 mismatches, ONE distinct input shape. After the fix: 0 of 414.
The fixture asserts the fix and both controls, because "quote every empty field" would also make the mismatch go away and would be wrong: an EMPTY ROW must still read back with no fields, and a two-field row of empties must still write a bare ,. Reverting the condition reddens the golden.
tycho-q and tycho-agg both write CSV through this function; q-check and agg-check are green either way, which is what makes this a silent data loss rather than a visible one.
62. core:toml guessed, in both directions — FIXED 2026-08-15
Pinned-by: test -f corelib/test/toml/main.ty Pinned-by: make corelib
The package header says parse "returns Err(what-was-wrong) on any malformed input (fail-closed; nothing is guessed)". Differentialled against Python's tomllib, it guessed eight ways, and refused four documents that are valid.
Accepted and invented a value (now refused):
| input | was | why it matters |
|---|---|---|
a = [1, 2 | [1] | the closing ] was never checked, so the last byte was stripped as if it were one and an element vanished |
a = "x" y | "x" | everything after the closing quote was discarded -- the fail-open shape of #34 and #59 |
a = 1 then a = 2 | 2 | a duplicate key overrode silently; in a config file that is an override nobody can see |
Refused although valid (now accepted). One cause: split(body, ",") knew nothing about nesting or quoting.
a = [1, [2, 3]] -> "bad number: 3]" a = ["x,y"] -> "unterminated basic string" a = ["a,b", [1, 2]] -> the same a = [1, [2, [3, 4]]] -> the same
[[1],[2]] worked, which is what kept this hidden: it is the nested case with no comma inside an element.
Still lenient, deliberately deferred: a = 01, a = 1__0, a = 1., a b = 1 and a repeated [t] header are all accepted where tomllib refuses. They are number-grammar and header rules rather than invented values or lost data, and each needs its own decision about how much of TOML v1.0 this subset means to enforce. The header's "nothing is guessed" is still too strong for those five and should be narrowed or the cases fixed -- naming them here so the choice is made rather than defaulted into.
The choice was made 2026-08-15, and measuring first moved one of the five out of the list. The sentence above calls a repeated [t] header a header rule "rather than invented values or lost data". Measured, it is lost data, and worse than the duplicate key this same entry fixed:
[t] -> ACCEPTED, and t.x is ABSENT
x = 1 the second [t] REPLACED the first table
[t] rather than merging with it
y = 2
The duplicate key at least kept the last value; this deletes a key nobody mentioned twice. It is refused now, by the same argument and with the same shape of message. Two non-regressions are gated beside it, because a blanket ban on a second header would pass the new case and break real documents: [[arr]] must still repeat, since that is how an array of tables is spelled, and [a] followed by [a.b] must not collide.
The other four stay accepted and are now WRITTEN DOWN in the header rather than left to the reader's assumption -- a = 01, a = 1__0, a = 1. and a b = 1, each with what it yields. The claim was narrowed to what the package actually guarantees: no input is accepted that would LOSE data or INVENT a value the text does not carry. "Nothing is guessed" was false for those four and is gone.
That line is drawn where it is because a config parser's job is to not lie about what the file said; refusing a document over a leading zero is a validator's job, and the header now says which of the two this is.
Control: dropping the duplicate-header check reddens toml and only toml.
core:markdown attribute break-out: probed, clean, and now pinned — 2026-08-15
Pinned-by: test -f corelib/test/markdown/main.ty Pinned-by: make corelib
Not a defect entry. A negative result, recorded because the surface is one this file has already caught once (#60, javascript: and data: hrefs emitted live) and because a clean security probe is worth nothing without the control that proves it could have failed.
#60 fixed the SCHEME. It says nothing about a quote closing an attribute early, which is the other half of the same surface:  puts attacker text where the alt value lives, and one unescaped " turns the rest into markup. Ten payloads across link href, image src, image alt, link text, code-fence language, data:text/html and vbscript: — every one already neutralised.
The control is what makes that a result. Defeating esc() (identity) and safe_url() (always true) in a corelib copy emits, from the same ten inputs:
<img src="a.png" alt="" onerror="alert(1)"> the attribute genuinely broken out of
<a href="javascript:alert(1">x</a>
<img src="javascript:alert(1" alt="x">
So the probe sees holes when they exist. Four of the ten are now in corelib/test/markdown/main.ty, and each of the two breaks reddens that golden independently.
Two of the ten are honestly vacuous and are labelled so in the fixture rather than counted. A quote in link TEXT lands in element content, where it is harmless whether escaped or not. And a code-fence language never reaches the output at all — there is no class attribute to inject into — so that leg confirms the language is discarded, not that anything is escaped. Counting either as an XSS test passing would inflate the result.
71. core:zip told the caller there was no traversal hazard — FIXED 2026-08-15
Pinned-by: make ar-check
Probed because docs/internals/audit-brief.md names archive parsing as the first place an external reviewer should look, and reading the code is what missed gcd and sign for months.
The offset arithmetic is sound, measured rather than read: sixteen archives built by Python's zipfile and then mutated — central-directory offset at 0xFFFFFFFF, past the end, and into the middle of the file; entry count at 0xFFFF; name length at 0xFFFF; local-header offset at 0xFFFFFFFF and near the end; compressed and uncompressed sizes at 0xFFFFFF00; a wrong CRC; an unknown method; an EOCD with nothing behind it; a two-byte runt. Every one fails closed — zero entries or empty bytes — and the unmutated archive still round-trips. No crash, no short read, no wrong data.
The defect is one sentence of prose. The header ended:
Entry names are treated as NAMES, not paths -- extracting never touches the filesystem, so there is no traversal hazard here.
The first clause is true. The conclusion is what a reader carries away, and it is wrong the moment they use the name — which is precisely what a caller of an archive reader does. list returns ../../etc/passwd and /abs/path verbatim (measured), which is CORRECT for a reader and is the entire hazard. The package that says "no traversal hazard" is one call away from the traversal.
Same shape as #60, where markdown escaped the text and not the scheme: the sentence is true about the thing it names and false about the thing that bites.
tools/tycho-ar was already right — it runs every name through path.safe_join before writing any file, so a refusal in entry 9 cannot leave entries 1-8 on disk. That is now the worked example the header points at, instead of a reassurance that no check is needed.
Pinned rather than fixed in code, deliberately. The names must keep coming back verbatim: silently rewriting one would hide the archive's real contents from a caller that *is* checking, which is worse than passing it through. The fixture asserts both hostile names survive the round trip exactly, and a plausible wrong fix — stripping a leading / in the writer — reddens it.
Controls: restoring the naive split reddens the golden, and so does dropping the array terminator check, independently.
63. A test lane that leaks a server degrades every other lane — FIXED 2026-08-15
Pinned-by: grep -q 'kill -KILL' tools/tycho-kvsrv/run.sh
make ci reddened at hash-check while hash-check passed on its own. The cause was not in either lane's subject.
tools/tycho-kvsrv/run.sh sent SIGTERM and then waited, with no KILL escalation. TERM is a REQUEST, and that server installs a handler for graceful shutdown, so a shutdown that blocks leaves the process alive and wait blocks with it. Found on this box: ten orphaned kvsrv processes, all ~935,000 seconds old — 10.8 days — at ~15% CPU each, roughly 1.4 cores permanently. Nine still named /tmp/kvsrv, a binary already deleted from disk; the tenth used the path the current script uses, which is what proved the leak was live rather than historical.
Same file, second defect: the trap set beside mktemp -d was silently REPLACED by trap cleanup EXIT INT TERM a few lines later, so $T was never removed either. /tmp held 624 tmp.* directories going back to July, 1.4 GB.
The consequence is the general lesson: a leaked server is not confined to its own lane. It puts a permanent load on the machine, and every lane that asserts a scheduling outcome then fails intermittently somewhere else entirely. That is how a kvsrv bug presented as a tycho-hash failure.
The class was swept. Six scripts background a process. Two lacked escalation: kvsrv, and scripts/tls_verify.sh — written earlier the same day, with the same bare kill. Both now escalate TERM → KILL after 2s and are verified to leave no process behind. The other four already had it, and scripts/ci.sh backgrounds only its own lane groups, which it waits on.
The first sweep produced two false positives by matching the words kill -TERM inside COMMENTS describing core:signal's test. A grep for a pattern is not a test for the behaviour — the same "a label is not a payload" mistake as the encoded-traversal legs in #57's neighbourhood.
64. gcd returned a NEGATIVE gcd where the answer fits — FIXED 2026-08-15
Pinned-by: make math-diff
corelib/math/math.ty@gcd documented itself "non-negative". It was not, for four input pairs, and only three of those are excusable.
abs(min int64) returns min itself -- negative -- and abs says so in its own comment, deliberately, because negating it would overflow. gcd called abs on both arguments and returned the loop's accumulator without re-normalising, so that negative could ride straight through:
gcd(min, 0) = min true answer 2^63, NOT REPRESENTABLE -- a width limit
gcd(0, min) = min same
gcd(min, min) = min same
gcd(min, min+1) = -1 true answer 1, REPRESENTABLE -- a WRONG SIGN
The last one is the defect. 1 fits in an int64 with room to spare; there was no arithmetic reason to return -1. The fix is return abs(x), which leaves the three unrepresentable cases following abs (documented) and corrects the fourth.
Found by differentialling core:math against Python, where the interesting part was separating the four. Python has bignums, so it disagrees on every int64 edge and reports six differences for abs, ipow and gcd alike -- and abs's and ipow's are both DOCUMENTED behaviour that the oracle simply cannot express. Reading each function's stated contract is what left one real finding out of six apparent ones.
The fixture asserts the fixed case and the three width limits separately, so a future change that "fixes" the limits by wrapping cannot pass by moving the wrong line. Reverting abs(x) to x reddens the golden.
65. sign() returned 0 for both infinities — FIXED 2026-08-15
Pinned-by: make math-diff
Same package as #64 and the same shape as the nine before it: *a comment claims a property, the code delivers it in one respect and not the one that matters.*
math.sign documents -1 / 0 / 1, and its comment explains the trick that makes one body serve every numeric type — "the zero is derived as x-x, so this works for int and float without a typed literal". It does work for int, and for every finite float. For an infinity x - x is NaN, every comparison against a NaN is false, so both tests fall through to the final return 0:
sign(inf) = 0 sign(-inf) = 0 (measured 2026-08-15, before the fix)
The two float values whose sign is *least* ambiguous were the two it got wrong. Nothing in the tree could have noticed: sign has no differential lane, and the golden recorded what the code did.
The obvious fix is a breaking change, which is why it is not the fix. z := zero$(T) reads better and gives a real typed zero — and it needs defaultable(T), which does not see through a newtype, while the numeric(T) in the signature does. Measured: math.sign(Cents(-7)) compiles today and stops compiling under zero$(T), with *"only int, float, bool, and string are defaultable"* pointing at a line inside corelib. A papercut is not worth a silent break in every newtype caller.
So the derived zero stays and the NaN case is answered where it lands. z != z identifies "x is an infinity or a NaN" — a state int arithmetic can never reach, so the int path is untouched by construction — and x == x * x then separates them, since +inf is its own square while -inf squares to +inf and NaN compares equal to nothing.
NaN still returns 0, now deliberately rather than by fall-through: it has no sign to report. That is the one behaviour worth arguing about, and it is written down instead of emergent.
Gated in corelib/test/math/main.ty with the finite int and float cases asserted on their own lines, so an edge-case fix cannot pass by moving an ordinary answer. Reverting the fix reddens math and only math — ok fmath printed beside FAIL math (output != golden) in the same run.
Then the package got the oracle it should have had. Two defects found by careful reading is not a repeatable process, so scripts/math_diff.sh (make math-diff, ~2.2s) scores min/max/clamp/sign/abs/gcd/ipow against Python. It is validated the only way a new lane can be — against the defects that already happened: reverting gcd's final abs() reddens it with 38 mismatches, reverting this entry's fix reddens it with 2.
And the first version of that lane would have caught only one of the two. It was integers throughout, and it reported:
math-diff: green (1197 scored answers match Python ...)
with the sign-of-infinity defect sitting untouched in front of it. min, max, clamp and sign are generic; instantiating them at int says nothing about their float instantiation, and *no float ever entered the corpus*. The lane was thorough, self-consistent, and blind to the entire half of the surface where the bug lived — which is the same failure as the zip fuzzing seeds that produced 1950 clean mutants against a parser returning 0 entries.
That makes it the tenth instance recorded here of the INSTRUMENT being the defect rather than the code, and the first one caught by asking a new lane the right question: not "is it green" but "would it have caught the bug I already know about?"
66. fmath.round was floor(x + 0.5), which is not rounding — FIXED 2026-08-15
Pinned-by: make math-diff
Found by pointing #65's new lane at the next package. round documents itself *"round half away from zero"* and implemented it as floor(x + 0.5). That addition rounds before floor ever runs, and it was wrong two different ways:
| input | returned | half away from zero |
|---|---|---|
0.49999999999999994 — the largest double below a half | 1.0 | 0.0 |
4503599627370497.0 — 2^52+1, already an exact integer | 4503599627370498.0 | itself |
The first is not a tie being broken the wrong way; the value is strictly under a half and it came back a whole unit out. 0.49999999999999994 + 0.5 is exactly halfway between the two doubles below and at 1.0, and IEEE ties-to-even picks 1.0. The second is worse in kind: round of an integer must be the identity, and at or above 2^52 every double *is* an integer, so the whole range was at risk.
0.5, 1.5, 2.5, -2.5 were all correct, which is exactly why the golden was happy — the package's own fixture asserts the cases anyone would think to write.
Decided by the FRACTION now: t := trunc(x), d := x - t, and the answer moves one unit when d reaches a half in either direction. x - trunc(x) is exact for every finite x, and t + 1.0 is only reached when x is *not* an integer, so |x| < 2^52 there and it cannot overflow. An infinity and a NaN both make d NaN, fail both tests, and return trunc's answer, which is themselves.
67. fmath.lerp did not return its endpoints — FIXED 2026-08-15
Pinned-by: make math-diff
Found in the same run, and it is the one defect in this batch that no comment claimed. lerp's only documentation is "linear interpolation, t in [0,1]" — nothing about lerp(a, b, 1) being b. The oracle asserted it anyway, because that is what interpolation *means*, and:
lerp(1e308, 1.0, 1.0) -> 0.0 (expected 1.0)
Not a rounding wobble. 1.0 - 1e308 is -1e308 in double precision — the b is gone before t is ever applied — so a + (b - a) * t returns 1e308 + -1e308, which is zero. The same subtraction overflows to an infinity for a far-apart pair of opposite sign, and then t = 0 returns NaN instead of a.
Both endpoints are special-cased now, which is what makes them exact. The middle of the range is unchanged: no attempt is made at the full monotonicity guarantee C++20's std::lerp carries, and that limit is stated rather than implied.
Worth separating from the rest of this file: #57 through #66 were all found by taking a written claim literally. This one was found by an oracle asserting a property the code never claimed — which is the argument for a differential over a re-reading, since a re-reading can only ever check what somebody wrote down.
68. pad_left/pad_right overshot on any pad wider than a byte — FIXED 2026-08-15
Pinned-by: test -f corelib/test/strings/main.ty Pinned-by: make corelib
pad_left's comment reads *"left-pad to width with pad (one byte)"*. The parenthesis is the whole specification of the precondition, and nothing enforced it: the loop counted the deficit in bytes (n := width - len(s)) and decremented by one per iteration while appending len(pad) of them.
pad_left("x", 5, ".") -> "....x" len 5 the documented case, correct
pad_left("x", 5, "ab") -> "ababababx" len 9 asked for 5
pad_left("x", 5, "é") -> "ééééx" len 9 a UTF-8 pad, same overshoot
The UTF-8 row is the one a formatter reaches for in real code — a box-drawing character, a middle dot, a non-breaking space — and it is the row where the result *looks* right in a terminal while being 80% too wide. Python's rjust refuses a multi-character fill outright with a ValueError, which is the same judgement reached differently: do not guess what the caller meant.
The deficit is counted in bytes on both sides now. A pad that does not divide it leaves the result short of width rather than over — a formatter survives a narrow column, and stopping early is what keeps a multi-byte pad from being split mid-codepoint:
pad_left("x", 5, "abc") -> "abcx" len 4 one pad fits, 1 byte short
Every caller in the tree passes a one-byte pad (" " or "0" — weblog, tycho-sheet, tycho-ed, examples/corelib/strings), so all of them are bit-identical and their lanes confirmed it. This is a fix for the case nobody had reached yet rather than a behaviour change under anyone.
The fixture prints the length beside every result, and that is deliberate: the padding characters look perfectly reasonable in a transcript, the *width* was the thing that was wrong, and a golden carrying only the text could not have seen it — the same reason ed-check asserts byte counts and sheet-check asserts float text. Reverting the two loops reddens strings and only strings.
69. core:sqlite's two halves disagree about a multi-statement string — exec FIXED, _params PINNED, 2026-08-15
Pinned-by: make corelib
The package is sound where it matters, and that is worth stating first because it is the thing a reviewer should check: run_stmt uses real sqlite3_prepare_v2 + sqlite3_bind_text, so exec_params/query_params are genuine prepared statements and there is no string interpolation anywhere in the file. The injection surface is closed by construction.
What is wrong is quieter. exec goes through sqlite3_exec, which runs every statement in the string. run_stmt goes through sqlite3_prepare_v2 with pzTail passed NULL, which compiles the first statement and discards the rest. Neither behaviour was documented, and they are opposites:
| call | rows that landed | what it returned |
|---|---|---|
exec("INSERT 'a'; INSERT 'b'") | both | Ok(1) — under-reported |
exec_params("INSERT 'c'; INSERT 'd'") | only 'c' | Ok(1) — no error at all |
exec's count is fixed. sqlite3_changes describes the most recent statement alone, so two inserted rows reported 1 against a function documenting *"Ok(the change count)"*. It reports a delta of sqlite3_total_changes now, which is exactly the rows this call changed. Single-statement callers — every caller in the tree — see the identical number.
exec_params's discard is PINNED, not fixed, and the distinction is the honest part. Making it fail closed means reading pzTail back as a string to test whether anything follows, and the alternative — scanning the SQL for a ; in Tycho — has to know about quoted literals *and* -- and /* */ comments, or it refuses valid SQL. That is a parser, on the security-adjacent path, and guessing at it is worse than naming it. The fixture asserts the current answer (3 rows, not 4) so a future fix changes that line on purpose rather than drifting, and run_stmt carries a gap: comment at the site.
A third, separate finding in the same header. The package's usage example opened with:
db := sqlite.open("app.db") or_return
defer sqlite.close(&db)
defer does not exist — it was refused on 2026-08-10 and the refusal is recorded in ROADMAP.md and twice in this file. A reader copying the package's own example gets *"a statement must be a declaration, assignment, or call — a bare expression has no effect"*, which does not contain the word defer and so does not tell them what is wrong. Swept the tree with a grep proved able to match on a synthetic first: exactly one instance, this one, now corrected in place.
That last one is the cheapest class of defect in this file and the one most likely to greet a newcomer, since a package header is what someone reads before they write anything. It is also invisible to every gate here — no lane compiles a doc comment.
70. A bound parameter was truncated at its first NUL — FIXED 2026-08-15
Pinned-by: test -f corelib/test/sqlite/main.ty Pinned-by: make corelib
Chased because #69 left it as an unprobed note, and it is the sharpest defect in this batch. sqlite3_bind_text was given -1 for the length, which tells SQLite to read to the first NUL. A Tycho string is length-carrying and may contain one. Measured:
tycho len(s) = 5 "hi\0zz"
stored = 2 bytes hex 6869 -- and the call returned Ok
Silent truncation at a trust boundary, with a success return. The failure mode is not lost characters, it is collision: two values the program treats as different become the same row. Anything storing a token, a key, a filename or a credential inherits that, and nothing anywhere reports it.
The fix is the argument that was already in the signature — len(params[i]). The value round-trips whole afterwards (6869007A7A), which also establishes that Tycho's FFI was passing the entire buffer all along; only the -1 discarded it.
The sibling sqlite3_prepare_v2(d.h, sql, -1, ...) has the same shape and is now len(sql). It is the cheaper case — SQL text is program-authored rather than attacker-supplied — and it is not covered by a test, because no fixture carries a NUL inside its SQL. Recorded as a limitation rather than claimed.
Two instrument errors on the way, both of the kind this file keeps recording. The first control run reported FAIL sqlite (tychoc compile) and that read like the control working; it was the fixture failing to build, because _ in this language is an ordinary variable rather than a discard and I declared it twice. A control that cannot compile accuses working code. The second: the assertion first asked SQLite for length(x), which for TEXT is *characters before the first NUL* by SQLite's own definition and so returns 2 on perfectly intact data — the oracle was wrong and the code was right. length(cast(x as blob)) asks the question actually meant. The golden written from the intended answer was correct both times; only the query was not.
72. The server's traversal defence is two layers and nothing could tell — GATED 2026-08-15
Pinned-by: make traversal-check
No defect. A structural gap, found while probing the server for docs/internals/audit-brief.md, and the more interesting kind: everything is correct and nothing could notice if it stopped being.
Sixteen hostile requests against a real tycho-httpd — raw ../, %2e%2e%2f, double-encoded %252e, ....//, a leading //, overlong UTF-8 %c0%af, absolute-form, a NUL in the target, .git/config, duplicate Content-Length, Content-Length beside Transfer-Encoding, an 8000-byte target — all refused, nothing leaked, and the server still served afterwards. resolve decodes percent-escapes BEFORE the traversal test, which is the right order and the one usually got wrong.
Then the control refused to work, which is where the finding is. Replacing path.safe_join(root, rel) with naive concatenation still gave 403 — so the probe would not have caught a safe_join regression, and for a while it looked as though safe_join were decoration. Defeating the other guard as well produced the leak and settled it:
safe_join | hidden_segment | GET /../secret |
|---|---|---|
| intact | intact | 403 |
| defeated | intact | 403 |
| intact | defeated | 403 |
| defeated | defeated | 200, canary leaked |
They are genuinely redundant, which is good design and a gating problem: either guard can be deleted with no observable change. server/run.sh's traversal legs stay green, the whole sweep stays green, and the defence is now one layer. The next change removes the other, and neither commit looked wrong.
make traversal-check (~16.4s, 16.36 / 16.35 / 16.81 s) closes it by defeating them one at a time in a COPY of the server and requiring the refusal to hold, with both-defeated as the control that must leak. Every sed asserts it changed the file, because a patch that silently does not apply reports the unmodified server as the broken one — a mistake already made twice in this session's own controls.
73. core:io holds its contract, including the one nobody wrote down — 2026-08-15
No defect. Recorded because the header states an unusually precise contract and because one property it does NOT state is the interesting one.
Every claim probed and every one holds. A missing path is Err(NotFound), a directory is Err(IsDir), an empty file is Ok(len 0) — the three-way split the header says was one empty bytes until 2026-07-26. A symlink loop, a permission-denied file, a 300-character name and an empty path all return Err rather than aborting, which is what *"Nothing here aborts"* claims. read(dir) is "", list(file) is [], write(dir) is false — the documented sentinels, each with one meaning.
The undocumented property is the valuable one. read_bytes("/proc/self/stat") returns Ok with 315 bytes, while stat(2) reports that file as 0 bytes. So the read does not trust st_size; it reads to EOF. That is the correct implementation and it defeats the classic procfs trap, where a size-based reader silently returns empty for every file under /proc and /sys — a wrong answer that looks exactly like an empty file, on precisely the paths a monitoring or diagnostic program reads.
It is not gated, and deliberately not gated here. A fixture asserting it would need /proc, which does not exist on macOS or under the Windows lanes, so it would either break those or carry a skip that makes it vacuous where it matters least. Naming it instead: if read_bytes is ever "optimised" into a single st_size-sized read, every /proc and /sys read in every Tycho program starts returning empty and no gate in this tree will notice. That is the note a future optimiser needs, and it is cheaper than a non-portable fixture.
74. core:net really does distinguish its four cases — 2026-08-15
No defect. Probed because the header rests its whole error model on one claim — *"recv(2) distinguishes those cases; only the return type was throwing the distinction away"* — and a claim that load-bearing is exactly where this file keeps finding code that delivers it in one respect and not the one that matters.
It delivers. Four genuinely different real conditions, four different answers:
| condition | result |
|---|---|
| the peer closes cleanly | Err(Eof) |
the peer stays silent past SO_RCVTIMEO | Err(Timeout) |
| a bad fd, and a refused connection | Err(Failed) |
| a host string with an interior NUL | Err(BadAddr) |
The probe needs no external control: four distinct answers from four distinct causes IS the control. If the type were collapsing cases — the pre-2026-07-26 behaviour the header describes, where empty bytes meant EOF, timeout and hard error at once — two of those rows would be identical. None are.
BadAddr deserves its own note, because it is the same defect this session found in core:sqlite. A host crosses to getaddrinfo as a char*, which ends at the first NUL, so an unguarded call resolves the PREFIX — "evil.com\0 .trusted.com" becomes evil.com. has_nul refuses it before any syscall, and the variant is deliberately not Failed because no syscall was made and retrying cannot help. That is #70's bug — a bound parameter truncated at its first NUL — anticipated at the design stage in one package and shipped in another. The lesson is the sweep, not the fix: any Tycho string reaching C as a char* without an explicit length has this shape, and core:net proves somebody already knew.
75. A password was truncated at its first NUL, so two credentials derived one key — FIXED 2026-08-15
Pinned-by: sh scripts/crypto_hygiene.sh
Found by the sweep #74 argued for: *any Tycho string reaching C as a char* without an explicit length has this shape.* 46 corelib externs take a string. Most are safe by construction — all six core:regex entries pass an explicit n, core:net refuses an interior NUL up front with has_nul, and core:sqlite was fixed earlier the same day (#70). One was not:
PKCS5_PBKDF2_HMAC(password, (int)strlen(password), ...)
strlen stops at the NUL. Measured, with a live control:
control: derive("alpha") != derive("bravo") true the probe CAN see a difference
derive("secret\0A") == derive("secret\0B") true two credentials, one key
derive("secret\0A") == derive("secret") true truncated to the prefix
The failure mode is collision, not weakness. The derived key is a perfectly good PBKDF2 output — of the wrong input. Two distinct passwords authenticate against each other, and any secret carrying a NUL is silently reduced to whatever precedes it. In a key derivation that is the most expensive place in the package for this bug to live.
cx_pbkdf2_sha256 takes the length now and never calls strlen, with the length fail-closed (< 0 or absurdly large refuses rather than guessing). Gated in corelib/test/crypto/main.ty with the control asserted beside it — two plainly different passwords must differ, or an all-equal run would report the bug as fixed. Reverting to strlen reddens crypto and only crypto.
Three packages, one defect class, three different outcomes. core:net anticipated it and refuses. core:sqlite shipped it and truncated data. core:crypto shipped it and collapsed credentials. The sweep is what made the third one findable, and it is the sort of pass that only happens when somebody goes looking across packages rather than down one — which is, again, the argument in docs/internals/audit-brief.md §4.
Still unswept in that class, named rather than assumed clean: core:http's URL, core:os's command string, core:datetime's TZ string, and crypto.ct_equal's two hex arguments. Each takes a char* with no length. Their inputs are program-authored rather than attacker-supplied in every in-tree caller, which is why they are lower priority and not why they are safe.
76. ct_equal compared two hex strings by their prefixes — FIXED 2026-08-15
Pinned-by: sh scripts/crypto_hygiene.sh
The second of the four #75 left named as unswept, and the answer is the one worth writing down carefully: a real collision, and not the vulnerability it looks like.
hexdec finds its own end with strlen, so an interior NUL shortened both inputs. Measured, controls first:
| case | before |
|---|---|
control "aabb" vs "aabb" / "ccdd" | true / false — the comparison works |
| both sides NUL-truncating to the same prefix | true — a collision |
| attacker supplies the NUL, the trusted MAC has none | false — fails closed |
| the same, reversed | false |
The MAC shape was never reachable. A computed MAC is library-generated and carries no NUL, so its length disagrees with the truncated attacker value and the answer was already false. Reporting this as an authentication bypass would have been wrong, and the third and fourth rows are what establish that — without them the first two rows read far worse than the truth.
It is still a collision for a caller comparing two supplied values, and this is the one comparison in the package whose entire job is not to surprise anyone. Lengths are passed now and a mismatch against strlen refuses outright, since hex never legitimately contains a NUL. Gated with both controls beside it; removing the length check reddens crypto and only crypto.
Two of the four remain unswept, unchanged: core:http's URL and core:os's command string. Both are program-authored in every in-tree caller. core:datetime's TZ string is the third and is the same shape. Naming them again rather than quietly dropping them, because a sweep that stops early and does not say so is how #62's "deliberately deferred" list ended up containing an entry that was really data loss (#69).
77. The interior-NUL rule was normative, swept once, and still missed the two worst sites — 2026-08-15
Pinned-by: make corelib
Not a defect. The most useful thing this session found, and it is about process rather than code.
Finishing #76's list turned up the history. core:os's header records that all four of its calls were probed for interior NULs on 2026-08-13, found to run "a DIFFERENT, SHORTER command than the one they were handed", and fixed — os.system("printf ok\0; printf BAD") ran printf ok and reported exit 0, so the caller was told its whole command succeeded. Re-probed today: -1 from both entry points, with a clean call returning 0/ok as the live control. It holds.
That note ends: *"The rule is docs/spec/14-ffi.md's, and the sibling guards are core:io's and core:net's."*
So the position on 2026-08-14 was:
- the rule was normative in the spec — a
stringholding an interior NUL does not survive the FFI round trip in either direction; - a deliberate sweep had been run for it;
- three packages carried explicit guards, each naming the others.
And the sweep stopped there. core:sqlite truncated a bound parameter (#70), crypto.pbkdf2_sha256 collapsed two credentials into one derived key (#75), and crypto.ct_equal compared two hex strings by their prefixes (#76). All three were found today, in one pass, by mechanically listing every extern that takes a string — 46 of them — rather than by thinking about which ones might be affected.
The lesson is not that anyone was careless. It is that a sweep run by the person who wrote the rule covers the sites they had in mind, and the sites they had in mind are the ones they had already fixed. The guards in os, io and net are *evidence the author understood the class completely* — and the two packages that got it wrong are the two that were not on the list. Understanding the bug is not the same as having enumerated its instances.
For anyone reviewing this tree: grep the declarations, do not reason about them. The command is in docs/internals/audit-brief.md §6 now, and this entry is why it is there.
78. The last two of the NUL sweep: a URL and a TZ string — FIXED 2026-08-15
Pinned-by: make corelib
#75 and #76 named four externs as unswept and #77 explained why leaving them named mattered. These are the last two, and both truncated.
core:http's URL. A URL crosses to curl as a char*. Measured: fetching file://…/real.txt\0/ignored returned real.txt's 18 bytes — the caller asked for one resource and got another, with no error. A program building a URL from user input inherits that. Guarded now, returning the null handle that already means "this did not run".
core:datetime's TZ. offset_at("EST5\0UTC0", 0) answered -18000 — the truncated prefix applied, not the string given. Refused as 0, which is already what an unparseable or empty TZ answers; a truncated zone is not a valid one.
The http fix was wrong the first time, and the probe is what said so. Guarding get/post left the body at 18 bytes, because get_body and get_status call http_get directly rather than through get. Three entry points, one guard, two still open — the caller-graph rule this repo's own CLAUDE.md states, missed by the person applying it. Re-probing after the fix is the only reason it did not ship half-done.
That closes the sweep: all 46 corelib externs taking a string are now either length-carrying or NUL-guarded. Written as a fact that will decay — a new extern is one commit away from making it false, and nothing mechanical enforces it. The check is grep -rE '^extern (\"[a-z0-9]+\" )?fn .*: string' corelib/, and it belongs in a reviewer's hands rather than a lane's, because the answer for each hit is a judgement about whether the input can carry a NUL.
79. json.parse_checked's error channel had a hole where the attacker is — FIXED 2026-08-15
Pinned-by: make format-diff
core:json's header is unusually clear about which entry point to reach for: parse_checked is *"the only one that can tell you the document you got back is the document you handed in."* It cannot, for one input class, and it is the class an attacker controls.
The parser is recursive descent, so nesting depth is C stack. Measured:
| nesting | before |
|---|---|
| 10000 | parses fine |
| 50000 | tycho: stack overflow -- recursion too deep, exit 1 |
The abort is *safe* — a runtime guard, not corruption, and tests/recursion/ exists to keep it that way. But it is an abort, and a process that dies cannot return Err. A server calling parse_checked precisely because it wants the failure to be recoverable dies anyway, on 100 KB of [.
Capped at 2000 with a new TooDeep variant. The check is a pre-scan, not a counter threaded through parse_value: one linear pass with no recursion of its own, so it cannot inherit the problem it exists to prevent. It tracks string state, because a [ inside a string is a character rather than a level — 5000 brackets inside a string still parse, and that non-regression is gated beside the boundary, which is asserted on both sides (2000 Ok, 2001 refused).
Adding the variant broke two exhaustive matches inside json.ty itself (err_offset, then err_reason) — the consequence tycho-verify names as the textbook consumer-breaking change, hit by the person who had just read that warning. Zero consumers outside the package match on JsonErr, so nothing else moved, and the compile-until-clean loop is what found both.
The measurement was nearly wrong twice. The first probe run reported 2001 -> Ok and a stack overflow at 50000 — because the build had failed on the first broken match and the probe was the STALE binary. Reading the exit code rather than the printed line is what caught it.
80. core:decimal holds every claim in its header — 2026-08-15
No defect, recorded because this is the money type and because the two claims most likely to be wrong are wrong in most implementations.
Ten rows, every one matching Python's Decimal exactly:
| claim | probed |
|---|---|
| the stored scale is preserved | 1.50 stays 1.50 |
cmp is exact across DIFFERENT scales | cmp(1.50, 1.5) is 0, both orders |
0.1 + 0.2 is 0.3 | it is |
| add across scales | 1.50 + 1.5 is 3.00, as Python gives |
rescale truncates TOWARD ZERO | -1.55 -> -1.5, -1.99 -> -1 |
The two that matter are the middle and the last. A cmp that compares coefficients without aligning scales makes 1.50 greater than 1.5 — the same number ordered wrongly, which in a ledger sorts and compares money incorrectly while every individual value prints right. And "truncates toward zero" is a claim about NEGATIVES: an implementation that floors instead would give -1.6 and -2, off by a cent in the direction that accumulates.
Not vacuous: cmp(1.5, 1.6) returns -1, so the comparison discriminates, and the rescale rows disagree with each other.
div already has a differential (40bac3d6, with a mode-swap control). Between that and this, the package's stated surface is measured rather than assumed.
81. csv.stringify writes a spreadsheet formula verbatim — DOCUMENTED, not "fixed", 2026-08-15
Pinned-by: make format-diff
A field beginning =, +, - or @ is EXECUTED as a formula when Excel or Google Sheets opens the file. =cmd|' /c calc'!A1 is the classic. Measured: stringify writes all four verbatim, and the quoting that does happen is incidental — "@SUM(1,2)" was quoted for its COMMA, and a quoted formula cell is evaluated anyway.
A differential against Python could not have found this, which is why it is worth recording next to format-diff: Python's csv writes it verbatim too, as do Go's and Rust's. Agreement with the oracle is the correct result here and the bug class survives it untouched.
It is not fixed, and "fixed" would be the wrong word for what a fix would do. The only defence is to MANGLE the value — prefix an apostrophe or a space — and that breaks parse(stringify(rows)) == rows, which the package states in its own header and which FRICTION #61 exists to protect. A serializer that quietly alters values is a worse defect than the one it prevents, and it prevents it only for the readers that happen to be spreadsheets: for a CSV another program parses, that apostrophe is data.
So it moves to the caller, at the point where the audience is known, and the header now says so with the four bytes and the guard spelled out. The passthrough is pinned in corelib/test/csv/main.ty so a future drive-by "hardening" has to be a deliberate change: wiring an auto-escape into stringify_delim reddens csv and only csv (verified — the first attempt at that control only DEFINED the helper without calling it, and reported green while changing nothing).
The general shape, for whoever reviews next: a differential is blind to any defect the oracle shares. Round-tripping, encoding and arithmetic are what it covers. What a downstream consumer DOES with a well-formed value is not, and that is where the injection classes live — this, and #60's javascript: href, and core:zip's traversal names (#71).
82. decimal's scale is a size SQUARED, and nothing said so — DOCUMENTED 2026-08-15
Pinned-by: sh scripts/bignum_diff.sh
No wrong answer. A cost, measured because core:decimal is the money type and rescale(d, scale) takes its scale from the caller.
k=200000 0.86 s each doubling of k roughly QUADRUPLES the time:
k=400000 2.47 s this is O(k^2), not O(k)
k=800000 9.81 s
k=1600000 38.94 s
The first probe stopped at "k=10000000 hit the 15 s timeout" and nearly went in as *"linear in the output size — you asked for ten million digits and got them"*, which would have been a comfortable and wrong conclusion. Four points instead of one is what showed the exponent. A single timing is not a complexity claim.
The consequence is what matters: a scale is not a size, it is a size squared. A scale field taken from a request at 2000000 is about a minute of CPU from one call — an amplifier, not a slow path. Every caller in this tree passes a literal or a column width, so nothing is exposed today.
No cap imposed. Any number would be arbitrary, and a cap that silently returns a differently-scaled answer is a wrong result where there was only a slow one. The cost is written at scale_up instead, with the rule that the ceiling belongs to whoever knows where the scale came from — the same division of labour as csv's formula escaping (#81) and zip's traversal names (#71).
83. bignum's division identity holds on every sign combination — 2026-08-15
Pinned-by: sh scripts/bignum_diff.sh
No defect, and the last package on the sweep. Recorded because core:decimal — money — is built directly on this, and because the identity is the one property here that is not a matter of taste.
(a/b)*b + (a%b) == a held on 64/64 sign combinations, and all four cases match C truncated division exactly, which is what the header claims (*"quotient toward zero; remainder takes the DIVIDEND's sign"*):
17/5 q=3 r=2 17/-5 q=-3 r=2
-17/5 q=-3 r=-2 -17/-5 q=3 r=-2
Both halves are gated, and one alone would not be enough. The identity holds for a FLOORED division too — Python's convention — so it cannot distinguish the two, and the four printed cases are what pin the documented one. A package that quietly switched to floored semantics would keep every identity check green while changing what -17 % 5 means, and decimal would inherit it.
Control: flipping mod to return the negated remainder reddens bignum and only bignum. scripts/bignum_diff.sh (against Python's integers) and q-check both stay green, which is the point — neither of them was asking this question.
84. make ci N=0 silently cut a differential to 4.8% of its corpus — FIXED 2026-08-15
Pinned-by: sh scripts/format_diff.sh
Found by re-measuring make ci N=0 for the gate table, which is not a place anyone expects to find a defect.
scripts/format_diff.sh:34 read its corpus size from a bare environment variable: N=${N:-400}. make ci N=0 sets N as a Make variable to skip the fuzz lanes, Make exports it, and the differential picked it up. The flag documented as "skip the (slow) fuzz lanes for a quick check" also emptied the generated half of every arm in the lane.
Measured, both directions:
N=0 sh scripts/format_diff.sh -> 20 paths, 9 refused -> lane exits 1
sh scripts/format_diff.sh -> 420 paths, 129 refused -> 0 violations
It was caught by luck, and the luck is the point. The lane exits 1 if fewer than 10 of the path corpus are refused — a guard against an escape-free corpus making the refusal half vacuous. With N=0 the corpus fell to the 20 hand-written cases, of which 9 are refusals. One more refusal in that hand-written list and make ci N=0 would have reported a clean run over 4.8% of the corpus, for as long as anyone cared to use the flag. The other nine arms shrank the same way and had no such guard: they simply scored fewer cases and said so in numbers nobody compares between runs.
Renamed to FMTDIFF_N, namespaced so no sweep-level knob reaches it. Verified both ways: N=0 now gives 420, FMTDIFF_N=40 still gives 60.
Two things worth taking from this. A gate that reads an unnamespaced environment variable is a gate whose corpus any caller can change by accident, and the more knobs a sweep grows the likelier the collision. And the diagnosis went wrong twice before it went right: first "flaky, probably contention" (it was perfectly reproducible), then "the probe output is truncated, zip is hiding it" (the probe was fine; the *corpus* was short). Both wrong readings produced real code — a checked-probe helper that stays because it closes a genuine hole in the same file, ten subprocess.run calls whose exit status nobody checked.
85. Two things a third party would have hit first — FIXED 2026-08-15
Pinned-by: grep -q 'no private reporting channel configured' SECURITY.md
Both found by *being* the third party rather than reasoning about one: install from the published tarball, clone the repo, run the first command each document tells you to.
1. SECURITY.md advertised a channel that is switched off. It said to use GitHub's private vulnerability reporting — "Report a vulnerability" under the Security tab. gh api repos/.../private-vulnerability-reporting returns {"enabled":false}. The button is not there, and the fallback was "contact the maintainer directly" with no address. Anyone holding a real finding had nowhere to send it, which is a bad moment to discover while holding one. Rewritten to describe the repository as configured, with a route that needs no infrastructure. Enabling the feature would make the original text true and is one API call; that is the owner's decision, not a documentation fix.
2. make check-links passed here and FAILED in a fresh clone. It is the command CONTRIBUTING.md calls *"the one gate to never skip"* and the one the pre-push hook runs, so a contributor's first action was red before they had touched anything.
check_citations.py resolved a cited hash with git cat-file, which finds any object in the LOCAL store — including one orphaned by a rebase, an amend or a deleted branch, present here and absent from every clone. 28 of 55 backticked hashes were exactly that, plus 5 in the commit <hash> prose form and one 8-char hash the width-7 rule never examined. The gate checks git merge-base --is-ancestor now.
All 34 were remapped rather than deleted: matching on the commit SUBJECT found a mainline twin for every one, so this was a rebase, not lost history, and the citations now point where the prose always meant.
The shape is the one this file keeps recording. A gate that verifies something *about the machine it runs on* rather than something about the tree will be green for the author forever. The only way to see it was to run it somewhere else — and "somewhere else" is exactly what §1 and §7 are asking for.
86. A gated timer nobody ran cost 1.7x, and the gate that caught it went unread — FIXED 2026-08-18
Pinned-by: none -- a timing claim, and a gate asserting a timing is a coin toss (the entry says so itself)
3a94b652 added TYCHO_ARENA_STATS timing. Its own header says the bump path is deliberately not timed "because clock_gettime is an order of magnitude more [than a bump] and would slow the very path the design exists to keep fast". That reasoning was right and aimed one function too high: the clock went into arena_free, which runs at every scope exit, and bench/binary_trees went from 302ms to 509ms.
The clock never executed. Every timing call is behind if (g_arena_stats), off by default. Merely *mentioning* st_now_ns() in arena_free's body was enough — a call in the body changes how the function is compiled into its callers. Measured by deleting only the two timing lines and keeping every counter: 511ms → 323ms. Splitting the pointer work into arena_free_hot and leaving the clock in a noinline cold half took it to 296ms, which is faster than the 302ms before the regression and matches v0.7.0's 295-298ms.
| tree | tycho | ratio to C |
|---|---|---|
| v0.7.0 | 295 / 298 / 298 ms | 37-39% |
3a94b652^ | 302 ms | 38% |
3a94b652 .. main | 509 / 512 / 514 ms | 65% |
| after this fix | 296 / 296 / 296 ms | 37-38% |
The gate was never the problem. bench/guard.sh reported it correctly, in the exact words tree-alloc perf regressed, as step [10/13] of make ci, from the day it landed. It shipped anyway because the sweep was red at that step and the line was skimmed. That is the failure this file keeps recording in a new costume: a green-except-one sweep trains you to read the verdict instead of the steps, and the one step you stop reading is the one carrying the finding.
It also went unnoticed because the standing instruction is to run the lanes that can redden for a change rather than the sweep — correct, and it means a lane whose subject is *the tree as a whole* has no change that obviously implicates it. Nothing about a docs commit says "re-run the perf gate".
Found only because an unrelated branch ran every lane it had skipped.
Found by pinning the instantiation note, 2026-09-11 (head 44b02447)
87. ~~tychoc and tychoc1 report a different number of instantiation frames~~ — FIXED 2026-09-11: tychoc now walks the chain (the owner's call)
Pinned-by: make test
Decided and fixed the same day. The choice was the owner's, because the two directions were opposite edits to a user-facing diagnostic: teach tychoc to walk the chain, or cap tychoc1 at one frame. Option A was taken — the outermost frame is the only line in a nested failure that the caller actually wrote, and it was exactly the one the single-frame form dropped.
tests/diag/generic_instantiation_note.ty still pins the one-frame shape (the common case, diagnosed directly rather than through a nested one), and tests/diag/generic_instantiation_chain.ty now pins the nested form that could not be written while the two disagreed.
A generic body is type-checked after substitution, so a call inside it fails at an instantiation and the compiler walks back up naming each instantiating call. The two compilers do not walk back the same distance.
On a two-level chain — main calls outer, outer calls inner, inner calls a concrete show(Point) at the wrong type — ./tychoc stops after two frames and ./tychoc1 emits three, adding the outermost call in main:
# both
...ty:21: error: argument type mismatch: argument 1 of 'show' is Name, expected Point
...ty:24: note: required from here -- this call instantiated the generic
# tychoc1 only
...ty:27: note: required from here -- this call instantiated the generic
27 | println(outer(Name("ada")))
On a one-level chain they agree byte for byte, which is why the fixture uses that shape.
Why it matters more than a wording difference. tests/run.sh judges every tests/diag/ golden with ${TYCHOC:-./tychoc1} (tests/run.sh:175@TYCHOC), while tools/prunner/main.ty@judge_diag hardcodes ./tychoc. So the two runners over the same corpus disagree about what the right diagnostic IS, and a golden recorded from one fails under the other. That is not specific to this diagnostic — it is a property of any diag fixture the two compilers word differently, and this is the first one found.
How it was found. Writing a fixture for the note, recording its golden the way judge_diag does it (./tychoc), and then running make test, which judges with ./tychoc1 and rejected it. The fixture was rewritten to the shape both agree on; the divergence is recorded here rather than papered over.
Which is right is an open question, and tychoc1's answer looks better — the outermost frame is the call the programmer actually wrote, and it is the one a reader needs to find the mistake. Deciding it means changing the other compiler to match, then a nested fixture can land with the fix.
How it was fixed, and the one thing the sizing got wrong. diag_flush (src/tychoc.c:1097@diag_flush) runs some 4,800 lines BEFORE GInst is declared, so it cannot walk g_ginsts at print time — the sizing below assumed it could. Instead GInst gained a parent index, a new g_inst_cur tracks which instance's body is being resolved (so a nested instantiation records its parent when it is interned), and gen_program's resolve loop — where g_ginsts IS in scope — builds the outward frame list into globals that a diagnostic copies when it is raised. Capped at 8 frames.
The predicted golden churn did not happen. The sizing called this a corpus-wide change wanting its own commit; make test came back 1039/0, the 1039th being the new fixture. No existing fixture's diagnostic ran through a nested generic, so nothing else moved. Worth recording: the cost estimate was the right shape and the wrong size, and only running it settled that.
The original sizing, kept for the record. The divergence is structural rather than a wording slip: tychoc's Diag carries a *single* instantiation site (src/tychoc.c:105@inst_file — one inst_file/inst_line/inst_src, not a stack), filled from one global pair (src/tychoc.c:80@g_inst_from) that gen_program's instance loop retargets per instance (src/tychoc.c:1068@g_inst_from). So it can only ever name the innermost frame; it is not dropping the outer one, it never had it. Making it match means giving GInst a link to the instance that instantiated it and walking that chain in diag_flush (src/tychoc.c:1097@diag_flush) — bounded work, but it touches a user-facing diagnostic for every nested generic in the corpus, so it is a golden-churn change and wants to be one deliberate commit. The cheaper direction, capping tychoc1 at one frame to match tychoc, is smaller and makes the diagnostic worse; recording it only so the choice is explicit.
Found by running make ci on a box that had never run it, 2026-09-11 (head 3310a50f)
88. ~~make server-check fails inside make ci and passes alone~~ — FIXED 2026-09-11: the watchdog inherits the cleanup trap and is cancelled with TERM
Pinned-by: make server-check
THE CAUSE, found by kernel-level signal tracing after four rounds of wrong hypotheses. server/run.sh is not the victim of an outside signal — it is the sender, and it kills itself by proxy:
- Its watchdogs are
( sleep N; srv_kill ) &(server/run.sh:701@sleep). That is a two-command subshell, so bash keeps a real subshell — and a real subshell inheritstrap cleanup EXIT INT TERMand$T. - It cancels a watchdog with
kill "$WD"— the default SIGTERM. - The watchdog runs the inherited
cleanup:rm -rf "$T", on the directory the parent is still using. - Nothing ever signals the parent, so it carries on and every later step fails with "No such file or directory".
A bpftrace probe on sys_enter_kill (not strace, which cannot be used here — it is ptrace-based and every ASan fixture fails under it, taking make test down at lane 2) shows it plainly: no signal anywhere in the run targets server/run.sh's pid, while that pid sends 57 TERMs to 57 distinct children in eight seconds, and each child then TERMs *itself* — the signature of a shell whose trap handler ran and re-raised.
Why it hid for four rounds, which is the lesson worth keeping. The minimal reproduction is wrong in a way that passes: ( sleep 30 ) & is a one-command subshell, and bash execs it in place, so there is no subshell and no trap. Measured both ways in one script:
A) ( sleep 30 ) & -> marker SURVIVED
B) ( sleep 30; : ) & -> CLEANUP RAN in the subshell; marker GONE
Every earlier attempt to reproduce used shape A and concluded, wrongly, that subshells do not inherit the trap. They do — when they are real subshells.
The fix: kill -KILL "$WD" at all five cancel sites. SIGKILL cannot be trapped. trap - INT TERM inside the subshell is not a fix — measured: the kill can arrive before the subshell has executed the reset — and a fire-and-forget timer has nothing to clean up regardless.
Two hypotheses this replaces, both recorded above and both wrong: an external signal to the shared process group, and a stale PID recycled under load. Neither survived the trace.
make ci fails at server-check (Makefile:404) on 3 of 3 runs. make server-check alone passes, server: OK, 0 FAILs — including once deliberately run concurrently with make corelib to load the box, which also passed. So it is not CPU contention.
What is observed. Partway through the lane its own mktemp -d directory stops being readable, *after* many checks in the same lane have already used it successfully. The last run failed immediately after ok SIGTERM: exit status 0:
ok SIGTERM: exit status 0 (clean; every accept loop returned and was joined)
grep: /tmp/tmp.ftf9Z3geSk/srv.err: No such file or directory
FAIL SIGTERM: no 'stopped after N requests' line on stderr
and an earlier run lost the built binary the same way — tycho-httpd: No such file or directory, exit 127, from a lane that had already pushed 156 requests through that binary. The script keeps running rather than dying, so whatever removes the directory is not terminating the shell.
Ruled out, each by testing rather than reasoning:
- *The EXIT trap firing in the watchdog subshell* (
server/run.sh:701@sleep,:14@trap). bash does not run a parent's EXIT trap when a( ... ) &subshell exits; probed directly. - *The TERM trap firing when the watchdog is killed* (
server/run.sh:706@kill, which sends the default SIGTERM while the script traps TERM). bash resets the trap in the subshell; probed directly, the marker file survived. - *Disk or inode pressure on /tmp* — tmpfs, 63 G with 1% used, 1046640 inodes free.
- *A script sweeping
/tmp* — nothing in the tree removes anything but its own$T, and nopkillpattern here matchestycho-httpd. - *One earlier theory that was simply wrong*: the first occurrence followed a
make cithat had been killed mid-flight, and3310a50f's message records it as collateral from orphaned cleanup. It then failed twice more with no killed predecessor. That commit message is wrong on this point and this entry is the correction.
Traced 2026-09-11, and the shape is now established rather than guessed. A inotifywait -m -r -e delete_self /tmp running alongside make ci caught the moment:
14:01:45 DELETE_SELF /tmp/tmp.eQ2Ej89ogL/outside/
14:01:45 DELETE_SELF /tmp/tmp.eQ2Ej89ogL/www/.hidden/
14:01:45 DELETE_SELF /tmp/tmp.eQ2Ej89ogL/www/img/
...
Three findings from that trace, each of which rules something out:
- The whole tree goes at once —
outside/,www/.hidden/,www/img/,www/fonts/. That isrm -rf "$T", socleanupran.cleanupis reachable only fromtrap cleanup EXIT INT TERM(server/run.sh:14@trap); nothing calls it directly, which was checked. The script keeps running afterwards, so it was not EXIT — it was INT or TERM, handled and returned from. - It is not only the server's directory. Six different
mktemp -dtrees died in the same second —eQ2Ej89ogL,ZFZNRnGXvp,yr3fCqvrXZ,y0lahWrj4d,xjPF9Csl4Y,VLRt0O0IqZ. So a signal reached *many* scripts at once. Most of them exit on it and are never heard from;server/run.shis the one that survives to fail visibly, because its handler returns instead of exiting. - No single lane sends it. Each of the four was run alone against a canary — a script in the same process group holding a
mktemp -dand reporting if its directory is destroyed under it.platform,corelib,restandappseach left the canary alive, andappsrun by itself reportsserver: OK. The failure needs the concurrency.
The leading hypothesis, explicitly not yet established: a stale PID. Several scripts here save a background $! and later kill it — server/run.sh:691 does exactly that to its watchdog. Under four concurrent lanes the process churn is high enough for a PID to be recycled between the save and the kill, and the signal then lands on an unrelated process. That fits every observation above (needs concurrency, hits multiple unrelated scripts, no lane does it alone) and is consistent with run_lanes (scripts/ci.sh:34@run_lanes) backgrounding the lanes into one process group. Confirming it needs PID-level tracing across a full run, which has not been done.
The canary is the reproduction harness and is worth keeping in mind for any "something killed my test" report here: a background sh holding a mktemp -d, with trap 'rm -rf "$T"' EXIT INT TERM, polling for its own directory. It turns an invisible group signal into a printed line.
Not caused by the commits around it — nothing under server/ was touched, and the same failure predates them.
Found by running make ci under clang, 2026-09-11 (head 1229adb1)
89. ~~make ci is gcc-only: vector-check's control does not discriminate under clang~~ — FIXED 2026-09-18: leg [3] picks its form from the measured control (the owner's call)
Pinned-by: grep -q 'the vector form does not exceed its control' scripts/vector_check.sh Pinned-by: make vector-check
Nothing pins this — the failing leg *is* the assertion, and it already fails closed. What is missing is a decision about what it should do on a compiler whose -O0 vectorises.
make ci under clang 22.1.8 reaches lane 51 of 51 and everything passes — make test 1039/0, corelib 46 green, corelib examples 37 green, server: OK, and the bootstrap fixpoint holds — except vector-check, which stops with:
vector-check: FAILED -- ./tychoc: at -O0 the [4]float CONTROL emits 8 packed
instructions -- leg [3] is not discriminating
That is the gate working, not a defect in Tycho. Leg [3] (scripts/vector_check.sh:158@discriminates) proves vector[N]T earns its keep by compiling the *same program* twice — once with vector[4]float, once with [4]float substituted in — and requiring the vector form to emit packed arithmetic while the plain-array control emits none. The control is the whole point: without it, packed instructions could be the auto-vectoriser rather than the type. When the control stops being a control, the leg refuses to certify rather than passing vacuously.
Measured, both compilers, same two programs (addp[sd]|mulp[sd]|divp[sd]|subp[sd]):
| compiler | opt | vector | control |
|---|---|---|---|
| gcc 16.2.1 | -O0 | 4 | 0 |
| gcc 16.2.1 | -O1 | 4 | 0 |
| clang 22.1.8 | -O0 | 12 | 8 |
| clang 22.1.8 | -O1 | 18 | 14 |
gcc's control is a perfect discriminator. clang emits packed arithmetic for a plain [4]float even at -O0, so control == 0 is a gcc property the leg reads as a C property. Note the vector form still emits strictly more than its control under clang (12 vs 8, 18 vs 14), so the *feature* is fine — it is the binary test that stops working.
Three options were on the table; the owner took a fourth, which is options 1 and 2 chosen per-compiler instead of globally:
- Skip leg [3] where the control is non-zero — rejected: it certifies *nothing* about vectors on clang, and a skip is the one outcome that cannot fail.
- Compare relatively (
vector > control) everywhere — rejected on its own, because it gives up an assertion gcc supports *today*. "The plain array vectorises not at all" is a bigger claim than "the vector wins", and a gate should not trade a claim it can hold for portability it can get otherwise. - Declare
make cigcc-only — rejected: the feature is fine under clang, as the table above shows. The gate was the only thing that was not.
What was done (scripts/vector_check.sh:177@exceed): the leg now measures the control first and picks the strongest form that control can support.
control == 0→ assert the absolute form, exactly as before. gcc is byte-for-byte unaffected — same assertion, same summary line, and no new output. Verified: a fullvector-checkunder gcc 16.2.1 passes and still prints "*the[4]floatcontrol emits none*".control != 0→ assert the relative form,vector > control, and *say so* on stdout with both counts. Under clang 22.1.8 all four legs now print, e.g. "*this host's cc vectorises the[4]floatcontrol (8 packed) … asserted vector (12) > control (8)*".
The summary line is the part that mattered most. It used to claim "*the [4]float control emits none*" unconditionally, so a relative run would have overstated its own result. It now interpolates $L3, which carries whichever claim was actually established — the same $L7 device leg [7] already uses for a skipped lane. A weaker run reports a weaker sentence.
The new assertion was proved able to fail, because a check that cannot fail is not a check. Leg [3] was fed the scalar program as *both* sides (v == s) and run under clang:
vector-check: FAILED -- ./tychoc: at -O0 the vector program emits 8 packed
instructions and its [4]float control emits 8 -- the vector form does not
exceed its control, so nothing measured here is attributable to the TYPE
exit=1
So the relative form discriminates rather than passing vacuously — which was the entire objection to option 2 as a blanket rule, and the reason it is used only where the strong form is unavailable.
This removes vector-check as the blocker; it does not make make ci portable, and the first full run after the fix is what established that. Under clang the gate now gets past leg [3] and travels 20 lanes further before shim-warn stops it on an entirely different gcc-ism — filed as 90 below. So the headline of this entry ("make ci is gcc-only") was the symptom of *two* causes and this entry only ever measured one of them. CONTRIBUTING was corrected to say exactly that, rather than inheriting either the old claim or the overclaim.
90. ~~shim-warn's locked baseline is gcc's: a gcc -Wunused-function dodge trips clang's -Wunused-variable~~ — FIXED 2026-09-18: the dodge is now an attribute, and make ci runs under clang
Pinned-by: grep -q '__attribute__((unused))' corelib/os/os_shim.c Pinned-by: make shim-warn Pinned-by: make shim-check
Found by fixing 89 and running further. With leg [3] no longer stopping the run, make ci under clang 22.1.8 reaches shim-warn (lane 3d2) and fails:
shim-warn: FAILED (the warnings the corelib shims emit moved)
cc: clang version 22.1.8 (Fedora 22.1.8-4.fc44)
@@ -0,0 +1 @@
+<file>:135:14: warning: unused variable 'osx_is_batch_ref' [-Wunused-variable]
The <file> was corelib/os/os_shim.c at the osx_is_batch_ref definition, which this entry's fix deleted — so it is named rather than cited, the citation gate quite correctly refusing a live reference to a symbol that no longer exists. The line carrying the replacement is corelib/os/os_shim.c:180@unused.
Measured, same tree, same command: gcc 16.2.1 — *"14 shim(s) compiled with -Wall -Wextra -Wdeprecated-declarations, 0 skipped; 0 warning line(s), matching scripts/shim.warn"*. clang 22.1.8 — the line above. scripts/shim.warn is empty, so the baseline encodes "gcc is silent here", which is a narrower claim than it looks.
The warning is aimed at a suppression, not at a defect. osx_is_batch (corelib/os/os_shim.c:181@osx_is_batch) is called from the _WIN32 spawn paths (corelib/os/os_shim.c:495@osx_is_batch and corelib/os/os_shim.c:613@osx_is_batch). On a POSIX build nothing calls it, so the file takes its address at file scope to keep gcc's -Wunused-function quiet:
#ifndef _WIN32
/* Nothing on a POSIX build calls it; say so rather than let -Wunused fire. */
static int (*osx_is_batch_ref)(const char *) = osx_is_batch;
#endif
That dodge is gcc-shaped. It converts an unused *function* into an unused *variable*, and clang warns about the variable — so the suppression is what fires. Nothing is wrong with the shipped code; this is the same gate-versus-language split as 89.
Not fixed here, because the options are not equivalent and the tree has no existing idiom to copy — grep finds no __attribute__((unused)) or maybe_unused anywhere under corelib/, runtime/ or src/:
- Mark the reference unused —
__attribute__((unused))on the variable. One line, silences both, but introduces an attribute this tree does not use and that is not MSVC-portable (the block is POSIX-only, so that may not matter). - Compile
osx_is_batchonly on Windows — wrap its definition in#ifdef _WIN32and delete the reference entirely. The most honest of the three: nothing on POSIX calls it, so nothing on POSIX need compile it. Costs a second#ifdefaround an existing function. - Record clang's line in
scripts/shim.warn— cheapest, and wrong: the baseline would then assert a warning the code should not be producing, and the gate exists to notice exactly that.
OPTION 2 WAS RECOMMENDED HERE AND IS WRONG, AND THE CODE SAYS SO. The comment directly above the function refuses exactly that edit:
*Deliberately OUTSIDE the _WIN32 split, though only the Windows spawn calls it. The logic is pure C, and while it sat inside the #ifdef no POSIX host could compile it -- so its gate scored a hand-copy instead, which passes whatever the shipped function does and drifts from it silently.*
corelib/os/os_batch_check.c:24@os_shim.c does #include "os_shim.c" and calls osx_is_batch directly, scoring the shipped function; scripts/shim_check.sh builds and runs it (32 assertions). Compiling the function only on Windows would have put that gate back to scoring a hand-copy — the precise defect the function was moved out of the #ifdef to fix. The recommendation was made without reading the eight lines above the function.
What was done instead — option 1, the attribute (corelib/os/os_shim.c:180@unused):
__attribute__((unused))
static int osx_is_batch(const char *name) {
and the osx_is_batch_ref pointer deleted. The dodge converted an unused *function* into an unused *variable*; the attribute states the same fact and creates nothing for either compiler to warn about. It is harmless where the function IS used — the Windows spawn path at :495, and the unit gate. The tree already uses __attribute__ (runtime/tycho_rt.c:247@constructor, :959@noinline), and the Windows build is mingw, which is gcc, so no target loses it. Option 3 stays rejected for the reason given above.
Measured, both compilers, same tree:
make shim-warn | make shim-check | os_batch_check | |
|---|---|---|---|
| gcc 16.2.1 | 0 warning line(s) | 16 ok, 1 skipped, 0 failed | 32 ok, 0 FAIL |
| clang 22.1.8 | 0 warning line(s) | 16 ok, 1 skipped, 0 failed | 32 ok, 0 FAIL |
Also corrected while here: that comment claimed the gate was selected by a TYCHO_OSX_BATCH_UNIT macro "defined only by that gate". No such macro exists anywhere in the tree — os_batch_check.c simply includes the file and supplies its own main. The comment now describes what the gate does.
With this and 89, make ci runs under gcc AND clang — the two causes behind one headline, closed separately.
91. make ci's wall clock moves ~2x between runs, so a speedup cannot be read off two totals — OPEN, and it is the MEASUREMENT, not the gate
Found while making the gate faster, which is when it matters most. Three full green sweeps on one machine, one afternoon, same tree bar the changes named:
| run | total | make corelib | conc | make test | ilp32 | fixpoint | fuzz-leak |
|---|---|---|---|---|---|---|---|
| cold | 1886 | 365 | 208 | ~157 | 133 | 149 | 435 |
| warm | 1123 | 379 | 196 | 144 | 123 | 142 | 168 |
| after the perf work | 568 | 168 | 85 | 82 | 62 | 49 | 167 |
The third row is the problem. corelib, conc, make test, ilp32 and fixpoint-check were not touched by that work and every one of them roughly halved. Whatever moved them is machine state — page cache, CPU boost residency, whatever else the box was doing — and it is large enough to swamp the effect being measured. fuzz-leak is the control that makes this legible: 168 then 167, flat across the same pair, so it is not a uniform clock error.
What this costs: 1123 -> 568 reads as a 2x win and cannot be claimed as one. The honest statement about that commit is "the gate is green at 568s", not "this made it twice as fast".
The method that does work, and the one used for the claim that was kept: run the two versions back to back in one session, alternating if the runs are long. docs-fences was measured that way — 275.4s serial, 53.2s parallel, minutes apart on the same box, same corpus, identical output bar the kernel's ephemeral port numbers — and 5.2x is a number worth writing down. Every per-lane figure in this file's perf notes that is NOT from such a pairing is an observation, not a measurement.
Not investigated: what actually causes the 2x. It would need pinned frequency, a dropped page cache between runs and an otherwise idle box, which is a bigger job than any optimisation it would be qualifying. Filed so the next person reads a total with suspicion rather than re-learning this.
92. ~~crypto_hygiene's ctgrind control is gcc-shaped: clang if-converts the branch it exists to have~~ — FIXED 2026-09-18: the control builds at -O0, the subject stays at -O1
Pinned-by: grep -q 'ct_run "$T/ctl" -O0' scripts/crypto_hygiene.sh Pinned-by: sh scripts/crypto_hygiene.sh
Third in the series, and found the same way as the second: by fixing the one in front of it and running further. With 89 and 90 closed, make ci under clang 22.1.8 reaches lane [3u7/27] and the apps lane dies there:
CONTROL DEAD: a branching digit decode was reported clean -- the
suppressions are hiding the subject, so [2] proves nothing.
The leg is a constant-time proof. hexdec_ct must not branch on secret key material, which memcheck can see if the key is marked undefined: a secret-dependent branch becomes a "Conditional jump depends on uninitialised value". Proving the *absence* of something needs an instrument known to detect its presence, so the control is a patched copy of the shim with the branchless select rewritten into an if/else chain, and it must redden.
gcc at -O1 leaves that chain alone. clang at -O1 if-converts it straight back into a branchless select — so the control emitted no branch, memcheck reported 0, and the leg correctly refused to certify. Nothing was wrong with the shipped code: the control had been optimised into the thing it was the negative of.
The fix is the one this file already uses eight lines up. The [c1]/[c2] probe is built at -O0 because gcc elided its malloc/free pair at -O1 and made *that* control report clean — same failure, other compiler, already solved here. So:
- the control builds at
-O0, which is what makes its branch survive any compiler. A control only has to prove the instrument can FIRE. - the subject keeps
-O1, because "the shipped decode has no secret-dependent branch" is a claim about optimised code and is worth nothing at-O0.
Measured, same tree, both compilers — the control scores identically, which is the point:
control (branching, -O0) | subject (-O1) | |
|---|---|---|
| gcc 16.2.1 | 4 reports | 0 secret-dependent branches |
| clang 22.1.8 | 4 reports | 0 secret-dependent branches |
The pattern across 89, 90 and 92 is one thing, and it is worth naming: every one was a *control* or a *suppression* that encoded a gcc behaviour as a C behaviour — a packed-instruction count, an unused-symbol dodge, an un-if-converted branch. None was a defect in Tycho, and all three failed closed, which is the gates working. What none of them could do was say "this is a property of the compiler I happen to be", which is the recurring shape to watch for when adding a control here.
93. ~~Five tool gates advertise a TYCHOC override, discard it, and test the BOOTSTRAP compiler~~ — FIXED 2026-09-18: absolutise the value instead of replacing it
Pinned-by: test -z "$(grep -lE "^TYCHOC=.\\$PWD/tychoc." tools/*/run.sh)" Pinned-by: make sim-check Pinned-by: make sheet-check
Found by needing the override. The tools/ half of the uninitialised-arena probe drives each tool through its own run.sh with TYCHOC pointed at an instrumented wrapper. Six gates reported "no instrumented run"; five of them for this reason:
TYCHOC="${TYCHOC:-./tychoc1}" # line 3: honours an override
[ -x "$TYCHOC" ] || { echo "no ./tychoc -- run 'make' first"; exit 2; }
TYCHOC="$PWD/tychoc" # line 5: throws it away
tools/tycho-sheet/run.sh, tools/tycho-db/run.sh, tools/tycho-flow/run.sh, tools/tycho-sim/run.sh, tools/tycho-ed/run.sh — all five, same three lines.
Two defects in one, and the second is the one that matters.
- The override on line 3 is documentation of a capability the next line removes. Anything setting
TYCHOCis silently ignored. - The gate then runs the C bootstrap, not the shipped compiler. The existence check on line 4 validates
tychoc1and the gate exercisestychoc. This tree states the opposite rule in three separate files (fuzz/run_leak.py:6@SHIPPED,fuzz/run_eqparity.py:4@SHIPPED,fuzz/run_typeparity.py:4@SHIPPED): *"The SHIPPED compiler, not the C bootstrap: every other gate runs tychoc1, and checks present in tychoc are absent from it."* Five tool gates are the exception nobody wrote down.
The comment says the intent was something else. It reads *"absolute: the probe in [N] is built after a cd"* — the problem being solved was a relative path surviving a cd, and hardcoding tychoc fixed that while changing the compiler as collateral. Absoluteness is obtainable without picking a compiler:
case "$TYCHOC" in /*) ;; *) TYCHOC="$PWD/${TYCHOC#./}" ;; esac
Not a latent failure, measured rather than assumed. tycho-flow was run with TYCHOC=$PWD/tychoc1 and no other change: green, including the 200-run reorder leg. So these gates are not passing *because* of the bootstrap compiler — they would pass either way, which makes this a correctness-of-scope defect rather than a red gate waiting to happen. Four of the five also passed under tychoc1 in the probe sweep; the fifth (flow) failed only under valgrind, for a reason that is the instrument (probe record, section 4).
Fixed, after measuring that it was safe to. The concern was that switching five gates to the shipped compiler might redden one of them, which is a scope decision and not a cleanup. So all five were run under tychoc1 first, with nothing else changed:
| gate | under tychoc1 |
|---|---|
sheet-check | green, 11s |
db-check | green, 10s |
flow-check | green, 10s |
sim-check | green, 3s |
ed-check | green, 3s |
None of them was passing *because* of the bootstrap compiler, so the fix takes nothing away. Line 5 now absolutises the value rather than replacing it:
case "$TYCHOC" in /*) ;; *) TYCHOC="$PWD/${TYCHOC#./}" ;; esac
Proved to do what it claims, in both directions. A TYCHOC pointing at a wrapper that touches a marker and then execs the real compiler:
fixed code gate rc=0, marker present -- the override was invoked
old code gate rc=0, marker ABSENT -- the override was discarded
The second line is the one that matters: without it, "the override works" is a claim about code that may always have worked. git stash and the same probe is the whole control.
One thing deliberately NOT changed. tools/tycho-debug/run.sh passes TYCHOC="$PWD/tychoc" on six invocations — but as an argument *to the tool under test*, telling tycho-debug which compiler to build the debuggee with, not as a clobber of its own override. Different thing, explicit at each call site, and whether the debuggee should be built by the shipped compiler is its own question. Named here so the next grep for this pattern does not read it as a missed instance.
Found by inspecting the whole locked surface, 2026-09-19 (head 2e561727)
The surface freeze records 41 builtins, 115 keywords and 559 corelib functions. Nothing had ever asked the obvious question of that list: is every entry on it actually exercised, and is every entry on it actually meant to be there? Three answers below; the method was to read surface.lock and grep the 1,389 tracked .ty files for each entry.
94. ~~floor is a shipped builtin that no fixture exercised~~ — FIXED 2026-09-19: tests/float_floor
Pinned-by: test -f tests/float_floor.ty Pinned-by: test -f tests/float_floor.out
Of 41 builtins and 115 keywords, exactly one entry appeared in no test or example: floor. It is not a stub — float -> float, specified at spec 29 and appendix D, implemented in both compilers (src/tychoc.c:4923@floor) — it simply had no fixture.
It was correct. Checked against C's libm on the six values a test would have pinned, all six identical:
tycho C
2.7 2.0 2
-2.1 -3.0 -3 <- rounds DOWN, not toward zero
0.0 0.0 0
-0.5 -1.0 -1
3.0 3.0 3
1e15+0.5 1e+15 1e+15
So this is a coverage finding, not a defect, which is the only reason it could sit there: a builtin that works is invisible until something counts. The fixture is now in the corpus (1039 -> 1040 passing) and the boundary values are chosen rather than arbitrary, with the reasoning in the file.
95. ~~Appendix E maps two concurrency clauses to the wrong section numbers~~ — FIXED 2026-09-19
Pinned-by: grep -q "^| §21 | spawn / Task / wait" docs/spec/appendix-e-conformance.md Pinned-by: grep -q "^| §22 | parallel-for" docs/spec/appendix-e-conformance.md
Appendix E exists so that "conforming" is a checkable claim: it maps every normative clause to a test. It mapped two of the three concurrency clauses to the wrong clause.
docs/spec/13-concurrency.md numbers its chapters 21 (spawn, Task, wait), 22 (parallel for) and 23 (channels and select). Appendix E filed *spawn/Task/wait* and *parallel-for* both under §23.x — channels — under a heading reading "§23–24 Concurrency & FFI". The cited fixtures were right and real; the clause numbers were wrong, so §21 and §22 read as having no conformance mapping at all while §23 carried three unrelated rows.
make spec-check cannot catch this, by construction. Its two checks are (1) Appendix A's collected grammar matches the chapters, and (2) every fixture cited in Appendix E exists. Nothing verifies that a cited clause exists, or that it says what the row claims. A conformance map can therefore point at any section number at all and stay green — which is how a heading that disagreed with its own chapter survived.
Renumbered to §21/§22/§23, rows put in order, and the heading corrected to "§21–26". The gate gap is recorded rather than fixed: a clause-number checker is a real piece of work and this entry is the measurement that would justify it.
96. The corelib does not use the visibility control the language already has, so 127 internal helpers are frozen public API — FIXED 2026-09-19
Pinned-by: grep -q '^fn _mag_cmp' corelib/bignum/bignum.ty Pinned-by: python3 -c "import json;d=json.load(open('surface.lock'));assert not [k for k in d['corelib'] if '._' in k], 'a package-private name is back in the freeze'" Pinned-by: make surface-check
Closed by options (1) AND (2) below, both in
f6f1025a. The extractor learned the underscore rule, droppingcompress._cause,image._causeandio._status; and 125 internal helpers across 28 packages were renamed_-prefixed. The freeze went 559 -> 431, andsurface.locknow carries no._name at all. The commit that did it never touched this file, so the entry sat marked OPEN, describing the pre-fix state, through two further commits and a STATUS.md refresh whose own surface row cited *this entry* for the change.make friction-checkcould not see it: an entry with no closure word is not scored, so a fix that lands without editing its entry is exactly the drift the executable log has no leg for. Found 2026-09-19 while reading the open list on macOS (FRICTION 100-102).
This entry's first draft was wrong and the correction is the interesting half. It claimed Tycho has no visibility marker. It has one: a leading-underscore name is package-private, enforced by the compiler.
./main.ty:6: error: 'mypkg._secret' is package-private:
a leading-underscore name is not accessible from another package
The original test proved only that bignum.mag_cmp is *not marked* private — it has no underscore — and that was read as "no mechanism exists". The mechanism was found afterwards in the compiler's own diagnostic list, by a sweep looking for something else entirely. A negative claim about a language needs the feature's absence demonstrated, not one example of it not applying.
The real finding, which survives. Of 559 corelib functions in surface.lock, 163 are never called qualified from outside their own package and 168 are absent from the catalogue; 127 are both — bignum.mag_divmod, bignum.pad_limb, base64.enc_char, base64.encode_impl. They are implementation details, they are publicly callable, and they are frozen.
Not because the language lacks a way to hide them, but because the corelib barely uses it: across every corelib/*/*.ty, only 2 distinct fn _name definitions exist, in 3 files. The feature is shipped, documented by its own error message, and unused where it would matter most.
Why this matters for 1.0. The roadmap defines 1.0 as *"a promise not to break people"*. That promise currently covers 127 functions nobody documented, nobody outside their package calls, and nobody intended to publish — renaming mag_add is a surface.lock diff a reviewer must approve. The freeze is doing its job perfectly; it is pointed at the wrong set. And the fix needs no language change at all, which is not what the first draft of this entry concluded.
A second, smaller defect found on the way. surface.lock records three names the compiler *forbids* calling from outside:
compress._cause (st: int)-> ZErr
image._cause (st: int)-> ImgErr
io._status (st: int)-> Result(void, IoErr)
These are package-private. They cannot be part of a public surface, because no other package can name them — yet the freeze tracks them, so renaming one is a reviewable surface change for something that was never reachable. The surface extractor does not know about the underscore rule that the type checker enforces.
Options, in the order they get cheaper:
- Teach the surface extractor the underscore rule — drop
_-prefixed names fromsurface.lock. Small, purely a gate change, and it fixes the second defect outright. It does nothing about the 127, which carry no underscore. - Rename the 127 to
_-prefixed and let (1) remove them from the freeze. Mechanical, uses a shipped feature as intended, touches no compiler code — but it is 127 renames across the corelib and every internal call site. - Leave it and document the split, accepting that the freeze covers more than it means to.
(1) is worth doing regardless of what is decided about (2): the extractor disagreeing with the type checker about what is reachable is a defect on its own terms. The measurement is the input; the choice is the owner's.
97. 34 of the compiler's 335 diagnostics have no pinned wording — PARTLY CLOSED 2026-09-19
Pinned-by: grep -q "^# expect: is package-private" tests/reject/pkg/privacy_cross/main.ty
The refusal is gated; the sentence is not. 593 flat reject fixtures assert that an invalid program *fails*, and an # expect: line pinning WHY is opt-in. tests/diag/ pins 42 diagnostics byte-exact. Together those cover 301 of the 335 distinct prose diagnostics the shipped compiler can construct. The other 34 can degrade to a worse sentence — or to a correct-but-useless one — with every gate still green.
That matters more here than in most compilers, because the diagnostics are a deliberate feature: the tree ships tests/diag/ precisely so the message text is an asserted contract, and the agent-facing skill file tells a reader that "the message usually carries the fix".
The measurement took four attempts and the first three were wrong, which is worth recording because the shape of the error repeated:
| attempt | method | result | why it was wrong |
|---|---|---|---|
| 1 | prose string literals in compiler/ | 99% unpinned | scraped source *comments*, not diagnostics |
| 2 | literals at _cerr/_err call sites, vs tests/diag only | 92% unpinned | ignored the 618 # expect: lines in reject fixtures |
| 3 | same, with # expect: included, prefix match | 27% unpinned | messages are built by concatenation, so a prefix match misses a pinned middle |
| 4 | any 18-character run present in the pinned corpus | 10% unpinned | — |
Each attempt produced a number that looked publishable. Only comparing them against individually checked samples showed which was true.
One of the 34 was the visibility rule itself, which is how it connects to 96:
'secretlib._scale' is package-private: a leading-underscore name is not
accessible from another package
tests/reject/pkg/privacy_cross asserts the refusal and carried no # expect: line, so nothing pinned the sentence that explains the language's only privacy mechanism. Closed: the fixture now pins it, and the suite went 1039 -> 1040 with the floor fixture from 94.
The remaining 33 are not closed here. Each needs a fixture that reaches it, and reaching some of them — overlapping mutable access, the sink-in-a-loop refusals — means constructing a program that trips a rule the corpus does not otherwise exercise. The list is the work item; the count is the argument for doing it.
98. 34 error-forwarding blocks predate or_return; the other 42 want a form that does not exist — FIXED 2026-09-25: or_else
Pinned-by: grep -q '"or_else"' surface.lock Pinned-by: test -f tests/or_else.ty
2026-09-25: closed by or_else (§14.6.1, 529fe940, tychoc1 4cb884a9), which returns any value of the enclosing function's type, so it also covers the sites returning false, -1 or None. The 34 went to or_return in f6f1025a, 11 more it missed in fdda3c5a; or_else took 79 (45 mapping the error, 34 returning a plain value). 13 remain on purpose, listed in STATUS.md's Ergonomics paragraph. The decision and why it beat or_return <fn> are in the RFC.
This entry exists because a recommendation was made from a count without checking whether the feature already shipped. An ergonomics sweep found 76 four-line match blocks whose entire content is "bind on ok, return on error", and concluded the language wanted an Odin-style or_return. Tycho has had or_return the whole time — spec §14.6, a reserved word since the lexical chapter, used in tools/tycho-db and corelib/httpd today. The sweep counted the sites and never grepped the keyword list it was proposing to add to.
With that corrected, the 76 split in two, and only one half is a language question:
| shape | count | status |
|---|---|---|
Err(e): return Err(e) — same error, pure forwarding | 34 | or_return expresses this today |
Err(e): return Err(Wrap(e)) / a fresh domain error | 42 | or_return cannot: §14.6 returns Err(err) *unchanged* |
The 34 are migration debt, not a missing feature. They sit in files that use or_return zero times — tools/tycho-flow/graph/graph.ty, tools/tycho-ed/buf/buf.ty, tools/tycho-sheet/main.ty — beside corelib/httpd/httpd.ty, which uses it five times. The construct arrived and the older code was never brought forward. That is a cleanup any contributor could do, with no surface change and no decision required.
The 42 are the real design question. or_return propagates the error it was given; every one of these sites deliberately changes it, because a net.listen failure surfacing to a caller as a SrvErr is the whole point of a domain error type:
match net.listen(host, port):
Ok(f): fd = f
Err(e): return Err(Listen(host, port))
An or_return that takes a mapping expression would cover all 42 and the 34 together. That is a surface change, it is demand-gated like every other, and the count above is the demand — measured the way packed was measured, which is the only part of the original sweep that was done right.
The lesson, which is the reason this is an entry and not a commit message: the sweep was rigorous about counting and careless about the premise. A feature request derived from real usage is still worthless if nobody checks whether the feature exists. surface.lock lists 115 keywords and would have answered it in one grep.
99. The corelib reference told readers core:net cannot poll, and it can — FIXED 2026-09-19
Pinned-by: grep -q "wait_readable(fds, ms)" docs/reference/corelib.md Pinned-by: sh -c '! grep -q "no .poll., .select., .epoll. or .O_NONBLOCK. anywhere" docs/reference/corelib.md' Pinned-by: make net-poll-check Pinned-by: python3 scripts/corelib_doc_check.py
Found by auditing which public corelib functions the catalogue never names. 41 of 431 were missing, and chasing net.wait_readable turned up something worse than an omission — the reference made a claim that is false:
*Every call blocks, by design and by omission: there is no
poll,select,epollorO_NONBLOCKanywhere in the package's 12 exports.*
Three errors in one sentence:
| claim | reality |
|---|---|
no poll anywhere | corelib/net/net_shim.c calls poll( 8 times |
| every call blocks | accept_wait(fd, ms) and wait_readable(fds, ms) take deadlines |
| 12 exports | 15 |
corelib/net/net.ty:218@wait_readable even carries the comment *"poll(2) underneath, not select(2): select cannot represent a descriptor at or above 1024"* — and make net-poll-check has been scoring exactly that behaviour on every sweep.
Why this is worse than a missing entry. The paragraph went further than omitting the feature: it told the reader the limitation was deliberate ("a stated limit rather than an oversight") and that lifting it "would cost a redesign of core:httpd's blocking read surface". Someone designing a server against this reference would architect around a constraint that does not exist, and would have no reason to look further — the text pre-empts the question.
Nothing caught it because nothing could. make check-links verifies links and citations; make version-check verifies STATUS claims; docs-fences compiles fenced code. No gate compares a prose claim about a package against that package's exports, and this claim is prose. The catalogue is the one doc whose subject is an inventory, so it is the one where drift is invisible: adding wait_readable to net.ty reddened nothing.
Corrected to describe both halves — the transfer calls block, readiness does not — and the three pins above guard that sentence.
The class is now gated too, which the pins alone did not do. scripts/corelib_doc_check.py (run by make check-links) asserts that every public corelib function in surface.lock is named in the catalogue. Package-private names are excluded, because the resolver refuses them from another package and they are not API. Prose cannot be checked — but absence can, and absence is the state this entry was found in: 41 of 431 functions the catalogue never mentioned, including the crypto key lifecycle that made the package unusable from the reference alone.
It carries its own selfcheck, because a scan that cannot report is indistinguishable from a clean catalogue. All 41 are now documented and the gate passes at 431/431. Two things it deliberately does not do: it cannot tell a *wrong* sentence from a right one (the defect above would still have needed a human), and a function documented by family — the entry reads ed25519_pubkey/sign/verify — needs an explicit exception, of which there are two.
The other 40 are omissions, not errors, and are now documented: the core:crypto opaque-key lifecycle (key_random/key_from_hex/key_export_hex/ key_len/key_free — the handle aead_encrypt takes, previously undiscoverable), the Ed25519 and X25519 constructors, strings.parse_int_checked / parse_float / slice_str / slice_bytes, decimal.from_str_checked, io.append_text / list_checked, and log.level_*. The strings ones are the sharpest of those: the agent-facing skill file tells a reader to reach for exactly those checked variants on untrusted input, while the reference a human reads did not mention them at all.
Found by running make ci on macOS / arm64 for the first time, 2026-09-19 (head 602aea34)
STATUS names *"a macOS/ARM64 build"* as one of four things between this project and being seen, and records that no such binary exists. This is the first time the gate has been run on that platform. Both compilers build native Mach-O arm64 unchanged — make tychoc in 2.0s, make tychoc1 in 34.1s — and make test is 1065/1065 (1057 fixtures plus the 8 standalone cases tests/run.sh adds; STATUS's figure is the fixture count and is correct).
Three defects below. None of the three is Darwin-specific in its cause; the platform is what made them visible, which is the argument for running the gate somewhere it has never run — the same argument that produced entries 88-90.
What a Darwin run does not cover, which is why this is a second opinion and not a replacement. The green carries 12 skips over 7 causes, each printing its reason. Six of the causes are lanes with no subject here: gdb (debug-check entirely, plus docs/debugging.md:26's transcript — the enumerated ALLOWED_SKIPS entry that exists for exactly this), -Wl,--wrap (ld64 does not implement it, so crypto_hygiene.sh cannot read released blocks), the glibc symbol floor, ilp32, resource.prlimit (server/run.sh's transient-accept leg), and vector-check leg [7] (--target x86-64-v3 on an arm64 host).
The seventh is a real coverage loss and should not be filed with the others: Apple's ASan ships no LeakSanitizer. fuzz-leak and the raytrace and mandelbrot leak legs do not run on macOS at all, so a leak this platform introduces would be caught only by the Linux host. That is the argument against ever treating a Darwin green as a substitute for the Linux one.
The lldb block at docs/debugging.md:37 — the one no Linux host can check, which is why it is the other half of that pair — RAN here and its breakpoint bound to a .ty line.
Two flags in Makefile@TYCHOC1_CFLAGS are gcc-shaped and clang drops them: --param inline-unit-growth=150 and -static-pie. The second cannot work on macOS at all — there is no static libc — so the release archive's "no versioned GLIBC symbol" property (scripts/release_content.sh:96) has no Darwin analogue and a macOS artifact would need a different claim. Reported, not fixed: nothing here builds a macOS archive yet.
100. docs-fences' verdict depended on whether some OTHER lane had built ./tycho — FIXED 2026-09-19
Pinned-by: grep -qE '^docs-fences: tychoc1 tycho$' Makefile
make ci failed the docs-fences lane on:
docs-fences: FAIL docs/debugging.md:73 is SKIPPED and not in ALLOWED_SKIPS
The fence is a two-line synopsis whose command is the bare tycho wrapper. scripts/docs_fences.py@ensure_repo_tool builds a missing tool on demand — atomically, because the workers run in parallel — but only for names matching ^tycho-[a-z0-9-]+$, built from tools/<name>/main.ty. tycho matches neither the regex nor that layout, and it needs a --shim besides, so it was never built. It ran as exit 127, tycho: command not found; the synopsis rule records a non-{0,1} exit as a SKIP, and an unenumerated skip is a hard failure.
The rule read docs-fences: tychoc1. ./tycho was a real prerequisite that was never declared, so the lane's verdict was decided by whether another target had happened to build it first. Run alone on a tree that had one, the same lane passed — which is why this has never been seen.
Makefile:86 says of tycho that it is in neither release archive, so *"no lane can redden for it and it is left alone deliberately — reported, not fixed."* That was true of its contents. It was not true of its existence, and this is the lane that quietly depended on it.
Declaring it (docs-fences: tychoc1 tycho) fixes it. Verified by deleting ./tycho and running the lane: docs/debugging.md:73 comes back ok, and the lane now reports 225 snippets and 21 shell fences where it reported 224 and 20 — the extra one is this fence, which had never actually run.
Nothing about this is Darwin-specific. A fresh clone whose first target is make docs-fences reddens identically on any platform.
101. Every Tycho server refuses connections under a burst on macOS, because the listen backlog is a literal 16 — FIXED 2026-09-19
Pinned-by: grep -q 'listen((int)fd, SOMAXCONN)' corelib/net/net_shim.c
make server-check failed inside make ci, and passed when run alone:
FAIL stalled-partial: could not open socket 28: [Errno 54] Connection reset by peer
That is the shape of entry 88 and it is not the same cause. netx_listen (corelib/net/net_shim.c@netx_listen) called listen(fd, 16). The accept queue holds connections that finished the handshake and are waiting for accept(), and the two kernels disagree about what a full queue means: Linux drops the SYN, so the client's TCP retries a second later and the overflow costs latency; Darwin and the BSDs reset the connection, so the overflow costs the connection. A backlog of 16 is therefore invisible on Linux and refuses peers on macOS.
server/run.sh's own abuse suite is what reaches it — 64 peers stalled mid-head against 4 workers, which is 64 connections arriving faster than 4 workers accept them. Reproduced deterministically by running the lane under 12 busy-loops on a 12-core machine, so the accept loop cannot keep the queue drained:
| backlog | under load | alone |
|---|---|---|
16 | FAIL 2 of 2 — ECONNRESET on the 28th peer, and on the second run also the 43rd, then EPIPE | OK |
SOMAXCONN | OK 2 of 2 | OK |
The load is 12 busy-loops on a 12-core machine, started and reaped by the harness around each run, so all four runs above saw the same load.
The control is the point: the *unchanged* lane passes on an idle machine, so the gate that found this cannot assert it, and a behavioural pin would be a load-dependent coin toss. The pin above is on the source for that reason.
SOMAXCONN rather than a bigger literal: it is defined by <sys/socket.h> and by <winsock2.h>, every kernel clamps it to its own ceiling, and it asks each platform for its own answer instead of encoding one platform's. This is a corelib fix, not a server one — net.listen is what every Tycho program calls, and the server is merely the program this repository points at it.
102. The loopback-split fix was applied to one of the two sites that needed it, and the other one ABORTS — FIXED 2026-09-19
Pinned-by: sh -c 'grep -q "chunk2 := to_str(data_of(net.read(cli2, 4096)))" corelib/test/httpd/main.ty'
make corelib failed the httpd package:
FAIL httpd (exit 1)
tycho: string index 0 out of bounds (len 0)
corelib/test/httpd/main.ty already carries this comment, on the text response at line 115:
*READ TO EOF, not once. One
net.readreturns whatever one TCP segment carried, and under load the loopback split this response between the headers and the body [...] roughly one CI run in three, while the same lane passed every time it was run on its own.*
Twenty-five lines below, the binary-body case still did a single net.read. The diagnosis was right, it was written down, and it was applied to one of the two sites that have the shape.
And the split is not bad luck: corelib/httpd/httpd.ty@write_response issues two net.write calls, the head and then the body. A reader that wins the race sees the head alone — which is the normal case, not the rare one. The text site degrades to rbody = and a golden diff; this one computes an empty body2 and indexes body2[0], which aborts the process.
Reproduced by rate, not by luck. 25 runs of the test binary against the same binary with the loop restored, each arm under 12 busy-loops on a 12-core machine:
| the read | 25 runs, 12 busy-loops on 12 cores |
|---|---|
one net.read (the control) | 12 aborted on string index 0 out of bounds |
| read to EOF (the fix) | 0 |
conn2 is closed before the client reads and the response carries Connection: close, so EOF is the end of it — the same justification the site above already gives. The golden is unchanged, which is the other half of the claim: this was never a disagreement about what the answer is.
Why no Linux run has seen it. It needs the reader to win a race against a two-write response, which needs the machine to be busy. make ci here runs 12 lanes wide on 12 cores. The entry above is the same story told by a different subsystem, and both were found in one run for the same reason.
103. Two claims about this project's own state that no gate can read — FIXED 2026-09-19
Pinned-by: sh -c '! grep -q "There is no Darwin or ARM machine here and no qemu" ROADMAP.md' Pinned-by: sh -c '! grep -q "102 entries; 93 closed" STATUS.md'
Found while writing 100-102 up, in the two documents those entries had to touch. Neither is a defect in the tree; both are the class entry 99 named — a prose claim about the project, in the one place a reader checks it, that nothing executes.
ROADMAP §1 asserted both halves of a contradiction, eight lines apart. The opening said Tycho *"IS built and tested on macOS / Apple Silicon [...] make ci has been run there"*; the same section closed with *"none of those binaries has been RUN. There is no Darwin or ARM machine here and no qemu."* The second sentence is the original and the first was added over it without retiring it, so the section simultaneously claimed the platform was covered and that no machine existed to cover it. A reader deciding whether macOS is supported got whichever answer they stopped reading at. Today's run settles it on evidence and only one sentence survives.
STATUS's defect-log row led with a number its own gate does not produce. The row read *"102 entries; 93 closed, 92 pinned by 66 commands"* and cited make friction-check. That command prints the closed, pinned, excused and unpinned counts — it prints no total, and 102 matches nothing in the file: at that commit FRICTION.md had 99 distinct entry numbers, 108 numbered headings (some numbers recur across sections) and 133 ### sections. The three counts it could have been are all different from it and from each other.
The other three figures in that row were correct, which is what makes this worth an entry rather than a typo: the page's stated rule is that every number is checkable by the command named beside it, and one number in the row was outside the reach of the command it cited, sitting first, where it reads as the headline. The row now quotes the gate's output and nothing else.
Neither is gatable in general — no lane can compare a sentence about the project against the project — but both are gatable *in particular*, which is what the two pins above do: each asserts the retired sentence has not come back. That is the same shape as 99's pins, and it is the most that class admits.
104. The executable defect log is executed by nobody — make ci has never called friction-check — FIXED 2026-09-19: option (1), the fact pins, as lane [1d2/13]
Pinned-by: grep -q 'make -s friction-check-light' scripts/ci.sh Pinned-by: grep -q '^friction-check-light:' Makefile Pinned-by: python3 scripts/friction_check.py --selfcheck
Found by adding eight pins to this file (96, 100-103) and going looking for the run that would execute them. There is none:
$ grep -rn friction scripts/ci.sh
<- no output: ci.sh does not mention it
$ grep -rn 'friction-check' scripts/ Makefile
<- the Makefile target, its two recipe lines, and the
.PHONY list. No caller anywhere.
The target is defined and called by nothing. It runs only when a person types it. STATUS said of this file *"the defect log is executable — a closed entry that stops being true turns a gate red"*, and that sentence was true of make friction-check and false of make ci, which is the gate anyone means by "a gate".
This is the exact shape the surface freeze was in, and that one was fixed. make ci's own description of surface-check reads: *"It was enforced only when somebody typed it — the freeze [...] had no lane in the sweep."* The remedy then was to add the lane. friction-check is in the state surface-check was rescued from, and the entry that rescued it is in this file.
The cost is why this is a decision and not a one-liner. Measured here on 2026-09-19:
| wall | |
|---|---|
make ci | 273s / 288s / 322s over three runs (see 91) |
make friction-check | 113s |
+39%, and most of it is duplicated work: of the 83 distinct pin commands, a large share are make <target> invocations for targets make ci has just run, so a naive append re-runs them. The options, cheapest first:
- Run the pins that are NOT
maketargets — the greps, thetest -fs, the inlinepython3 -cs — as acilane, and leave themake <target>pins to the standalone run. Nearly free, and it covers the pins that assert a *specific fact* rather than re-running a suite. It does not score every pin. - Add the whole thing as a lane. Complete, and pays 113s for coverage
cilargely already has. - Teach
friction_check.pyto skip a pin whose commandciran in the same invocation, then add it. Correct and cheap at runtime; it needs the two to share a record of what ran, which neither does today. - Leave it standalone and fix the STATUS sentence, accepting that the log is executable on demand rather than continuously.
(1) was taken, and (4) was done regardless — the STATUS sentence was wrong however this was decided. make friction-check-light is lane [1d2/13], and it costs 0.16s, not the 113s the full run costs:
friction check (--light): ok (65 of 103 closed entries scored here by 65 fact
pins; 37 entries and 21 suite pin(s) DEFERRED to `make friction-check`, which is
the full run)
(Closing this entry moved those figures itself — it added the 103rd entry and three fact pins. Quoted post-closure, which is the state the lane is in.)
The split is one rule, not two. friction_check.py@split already sorted pins into heavy and light to decide *scheduling width* — suites 2-way, facts 8-way, because 46 commands 8-way took 9m29 against 2m07. --light scores exactly the light half, reusing that function rather than re-deriving the boundary, so the ci lane and the standalone run cannot drift into disagreeing about which pins are the expensive ones.
The summary names what it did not run, which is the whole difference between a subset and a smaller-looking whole. An entry pinned *only* by suites is scored by nothing here, so it is counted and reported as deferred rather than dropped — otherwise this lane would print ok in the same shape as the full run while covering 65 of 103 entries.
Both directions are controlled, because a gate that cannot redden proves nothing and a subset that reddens for everything is not a subset:
| control | [1d2/13] | make friction-check |
|---|---|---|
| a fact pin that stops holding | FAILED, naming the entry | FAILED |
| a suite pin that stops holding | ok — deferred count rises 37→38 entries, 21→22 pins | FAILED, naming the entry |
Selfcheck legs [7]-[10] pin the classification itself, in both directions: classify everything light and the lane re-runs the suites it exists to avoid; classify everything heavy and it scores nothing while still printing ok. [10] covers the deferral accounting specifically.
What is still standalone-only, stated plainly rather than left implied: the 21 suite pins, and the 37 entries that carry no fact pin. Those are asserted by make friction-check, which a person still has to type. The trade is deliberate — they re-run lanes make ci has just finished — and it is the reason this is option (1) and not (2).
105. A backtick in a ci.sh step banner is a COMMAND SUBSTITUTION, and two of them were live — FIXED 2026-09-19
Pinned-by: python3 scripts/check_scripts.py Pinned-by: python3 scripts/check_scripts.py --selfcheck
scripts/ci.sh announces each lane with step "<label> (<rationale>)". The string is double-quoted, so a backtick inside it is not punctuation — the shell runs the text and splices its output into the banner. Two were live.
The pre-existing one, scripts/ci.sh:103. Its rationale quoted the two diagnostic examples the lane exists for. Every sweep printed:
... and numbers its lines from 0, so died as -- no path, wrong line.
Both examples gone, and two command not found lines in the log that nobody read. The lane still did its job; it just stopped being able to say what it was for, which for a file whose rationales are its documentation is the whole value.
The one added the same day, and it was worse. The friction-check-light banner (104) backticked make friction-check — a real target. The sweep RAN it, 113s of it, and spliced its output into the banner mid-sentence. It also ran a bare > Pinned-by: from the same string, which is a redirect, leaving an empty file named Pinned-by: in the repository root. That file is untracked and non-ignored, so it moved status.sh --fingerprint, and two consecutive green sweeps printed NOT RECORDED -- the tree changed while the sweep ran before the cause was found. The gate was right every time; the tree really was changing under it, because the gate itself was changing it.
sh -n is blind to this by construction. The script is valid; it simply does something other than describe itself. make script-check parses every tracked .sh and said ok on both instances.
Now gated, as leg [3] of scripts/check_scripts.py — the file that already exists for "valid syntax, silently wrong", which is exactly this shape. It refuses an unescaped backtick in any step "..." line of a tracked .sh; \ is the cure and is accepted. Selfcheck legs [c5]-[c7]` hold it to reddening on a live backtick, ignoring an escaped one, and ignoring backticks in comments, where the file's other 8 are and where they are harmless.
Both instances are fixed and ci.sh:90 prints its examples again.
Caveat on the pin, stated because it matters. make script-check is not in make ci — it is in the pre-push hook, which is deliberate and documented there ("THE SWEEP IS YOURS TO RUN"), and which requires make hooks to have been run. core.hooksPath is unset on the machine this was found on. So this guard runs at push time for a developer who activated the hook, and not in the sweep. See 106.
106. parse-check is RED, has been all day, and is in neither make ci nor the hook — DRIFT CLEARED and corpus-check GATED 2026-09-19; the divergence CLOSED 2026-09-22; the lane's absence from the sweep is still the owner's call
Pinned-by: grep -q 'make -s corpus-check' scripts/ci.sh Pinned-by: make corpus-check
Entry 104 found friction-check defined and called by nothing. That prompted the obvious next question — *what else?* — and the answer is worse than a lane that does not run: a lane that does not run and is failing.
The coverage map, measured 2026-09-19. Every *-check/*-warn target was matched against what scripts/ci.sh invokes and what .githooks/pre-push runs:
| gate | make ci | pre-push hook |
|---|---|---|
script-check, check-links, version-check, spec-fast, surface-check, rtparity, fuzz-quick | some | yes |
contrast-check, site-code-check | no | yes, on a gh-pages push only |
corpus-check | no | no — parse-check runs it, and that does not run either |
mingw-warn | no | no — needs the mingw cross toolchain; platform-gated, fair |
parse-check | no | no |
tychoc1-check | no | no |
The hook's own header says the sweep is deliberately not in it — *"THE SWEEP IS YOURS TO RUN"* — so the hook being a subset is by design. It also requires make hooks; core.hooksPath was unset on the machine this was found on.
Those last two are the ones ROADMAP calls load-bearing. Its self-hosted compiler section reads: *"Two gates carry it. make parse-check scores its front end against ./tychoc's own verdicts file by file over the whole tree [...] make tychoc1-check substitutes it into the real runners."* Neither runs unattended. The differential oracle that the project decided, on 2026-09-11, to keep src/tychoc.c alive for is scored only when somebody types it.
tychoc1-check passes — 36 lanes, 0 failed, 188s.
parse-check FAILS, and it fails for two different reasons:
- Three count literals drifted.
tests/*.tyis 289 against an expected 288, the tree is 1401.tyfiles against 1375, leg15 refused is 52 against 51, and 617 reject fixtures have 593 classified rows. Mechanical. - A real divergence between the two compilers, which is not mechanical:
MSG-DISAGREE tests/reject/pkg/builtin_via_package/main.ty
tychoc : package 'strings' has no symbol 'len'
tychoc1: 'len' is a builtin, not a member of package 'strings' -- call it
directly: len(...)
That is FRICTION 87's shape exactly — a user-facing diagnostic the two compilers word differently — and 87 is the entry that justified keeping the oracle at all: it found a worse diagnostic in the shipped compiler, and fixing src/tychoc.c improved what users get. Here tychoc1 again says the more useful thing. Per the asymmetry recorded in architecture, the question is *which is right*, not *make tychoc1 match* — so this is the owner's call and is deliberately left red rather than papered over.
It went red today. tests/*.ty crossed 288 → 289 at f0078c52, the commit that added tests/float_floor.ty to close entry 94. Five commits have landed since, and every one of them recorded Verified: make ci GREEN — truthfully, because make ci does not run this gate. The sweep was green and the tree was not.
Why the literals are NOT being quietly updated here. Greening the counts would leave one failure — the divergence — and an entry that reads "mostly fixed" invites exactly the mechanical pass that buries it. The counts and the divergence went red together and should be read together.
What was done, 2026-09-19
(1) and (2) are done; (3) and (4) are left.
The drift is cleared. Seven literals, not the three the first pass counted — each one only became visible once the one before it was fixed, which is its own small lesson about a gate nobody runs:
| literal | was | is |
|---|---|---|
run.sh n_acc / leg1 (tests/*.ty) | 288 | 289 |
verdict_diff.py EXPECT (whole tree) | 1375 | 1401 |
run.sh leg15 refused | 51 | 52 |
run.sh TYPE / SEMANTIC split | 166 / 273 | 175 / 288 |
run.sh --typecheck refused | 593 | 617 |
plus compiler/reject_class.tsv regenerated by scripts/classify_rejects.py (593 → 617 rows).
And the three censuses were stale, which is the more interesting half. The AST, RESOLUTION and TYPE censuses are the legs that exist because *an accept/reject verdict is blind to a parse that succeeds with the WRONG tree*. They had not been recorded since 7b12d0ee, and f6f1025a — the or_return migration, 34 sites, -69 net lines of .ty — never touched them.
They were not re-recorded on the grounds that the corpus grew. Blessing a census because the tree got bigger is exactly the mechanical pass that certifies a regression. The full per-kind delta was computed first, and every one of the 32 moved kinds is accounted for:
| kind | delta | why |
|---|---|---|
e.OrRet | +33 | the migrated sites (the 34th is inside a doc comment) |
s.MatchS | −29 | the match blocks they replaced |
a.variant / a.bind | −57 / −45 | their Ok(x)/Err(e) arms and bindings |
e.ErrE / s.ReturnS / s.PassS | −29 / −30 / −11 | the return Err(e) bodies and Ok: pass arms |
s.ExprStmt / e.Call | +20 / +20 | the f() or_return statements now in their place |
d.fn, d.struct, d.field, s.IfS, … | +1 each | today's new fixtures |
Every decrease is the migration; every increase is a fixture. Nothing is unexplained, which is the only basis on which a census golden should move.
corpus-check is now lane [1d3/13], at 0.04s. It cannot see what parse-check sees — but it sees the one thing that broke it, for nothing, and it would have reddened at f0078c52 on the day.
What was still red, deliberately, and is not any more:
MSG-DISAGREE tests/reject/pkg/builtin_via_package/main.ty
make parse-check failed on that and nothing else, and which compiler was right was the owner's call under the recorded asymmetry, exactly as 87 was. It was settled by 91a2b294 (the wording) and the bookkeeping drift it left behind was cleared by cf774335. Re-run 2026-09-22 after tools/tycho-du landed: parse-check: all green in 35.9s over 1406 files — leg5/6/8/10/11/15 all disagreements=0, and no MSG-DISAGREE line anywhere in the output. The two compilers now agree on every file in the tree.
What is still the owner's call is only the cost question below, and the cheaper half of it has moved: parse-check is 35.9s, not the 35s-plus-a-red-lane it was when this entry was written.
Still uncalled by any automatic run: parse-check (35s) and tychoc1-check (188s). +223s on a ~290s sweep is the same cost argument as 104 and a much bigger bill, so it stays a decision rather than a diff.
107. strings.len(...) sent the reader looking for a symbol that was never going to exist — FIXED 2026-09-19
Pinned-by: sh scripts/builtin_qualified.sh
The divergence 106 left standing. make parse-check leg15 scores the two compilers' diagnostics against each other by MESSAGE, and it found this:
tychoc : package 'strings' has no symbol 'len'
tychoc1: 'len' is a builtin, not a member of package 'strings' -- call it
directly: len(...)
tychoc1 is right, and the difference is not stylistic. Someone arriving from Go or Python reaches for the package qualifier first; the reference compiler's answer sends them to read core:strings looking for a len that was never going to be there, while the self-hosted one names the mistake and the cure. This is entry 87's resolution repeated — tychoc1 is the oracle, src/tychoc.c is normative, and when they disagree the question is which is right.
The reference compiler already had this branch. It simply asked the wrong question:
if (sig_find(nominal_name(e->sval)))
die_at(..., "'%s' is a builtin, not a member of package '%s' ...");
sig_find is the SIGNATURE table. A builtin with a declared signature is in it; a generic one — len, push, keys, map_get — is implemented specially and has no Sig entry at all, so it fell straight past into the did-you-mean path. The branch was written for exactly this case and could not see half of its subjects.
Three partial lists, and none of them was the builtin set. sig_find misses the generics. is_ufcs_builtin is about method syntax. shadows_builtin is about a declaration hiding a builtin — and measured against the 41 in surface.lock it is missing exit, get, read_file and wait. Swapping one for another fixed 39 of 41 and left get and wait still diverging, which is how the fourth list earned its place rather than being assumed into existence.
So is_builtin_name() carries all 41, and its completeness is GATED, because a fourth list that nothing checks is the same defect waiting to happen. scripts/builtin_qualified.sh reads the builtin set out of surface.lock, compiles a qualified call for every one, and requires both compilers to name it and to agree word for word. A builtin added to the freeze without being listed in the compiler reddens there.
builtins scored, from surface.lock | 41 |
| both compilers name it and agree | 41 |
| before the fix | 39, with get and wait diverging; 1 before that |
The control matters as much as the legs: a name that is not a builtin must still get has no symbol, without which the script would pass by answering every input the same way. And the gate is proved to redden — deleting len from is_builtin_name gives FAIL len: tychoc did not name it a builtin.
Now lane [1d4/13]. Found a day late, by a gate nothing runs (106).
108. A retry that never waited: 50 iterations in 0 ms — FIXED 2026-09-19
Pinned-by: grep -q 'time.elapsed_ms(sw) >= 5000' corelib/test/net/main.ty Pinned-by: sh -c '! grep -qE "^ +for t := 0; t < 50; t \\+= 1:" corelib/test/net/main.ty'
make ci went red on corelib/test/net, and green when the package was run on its own:
poll_two=[c0,c1] expected, got [c1]
poll_hup=[c0,c1,c2] expected, got [c1]
poll_until exists because a byte written on loopback has not necessarily landed when the next statement runs. Its comment says so, and says the retry is *"for ARRIVAL, not for the verdict"*. The intent was right. The loop was not:
for t := 0; t < 50; t += 1:
got = poll_str(net.wait_readable(fds, 200), fds)
if got == want:
return got
Fifty iterations at a 200 ms deadline reads as ten seconds of patience. It is zero. net.wait_readable returns the instant any descriptor is ready — so once c1 was readable, every one of the 50 calls returned immediately and the whole loop finished in microseconds, never once waiting for the second write to land. A busy spin wearing a retry's clothes.
Measured, because "it returns early" deserves a number. A probe with one socket readable and one that never will be, timing both shapes to the same unreachable target:
| shape | elapsed before giving up |
|---|---|
for t := 0; t < 50; t += 1 (as written) | 0 ms |
| bounded by the clock (the fix) | 1002 ms, its whole budget |
That is the entire defect in two numbers: the old form's patience was not short, it was absent.
The fix bounds the wait by time.elapsed_ms instead of an iteration count, with a 5 ms sleep between polls — without the sleep the loop still spins, it just spins for the whole budget and burns a core doing it. A passing run still leaves on the first poll that matches, so nothing gets slower.
What is NOT claimed: a reproducing control. The failure needs the full parallel sweep; 15 runs of each shape under 24 busy-loops on 12 cores produced 0 wrong of 15 for both, so this was not closed by making a red test green — it was closed by reading what the loop does and then measuring it. The goldens are unchanged.
Same family as 102, which was also a corelib test assuming loopback timing, also found by the macOS sweep, and also invisible on an idle machine.
109. The macOS artifact would have shipped unexamined — FIXED 2026-09-19: darwin-arm64 built, reproducible, and content-gated
Pinned-by: grep -q 'check_native_darwin' scripts/release_content.sh Pinned-by: sh -c 'grep -q "mingw_skip=" scripts/release_content.sh' Pinned-by: sh scripts/release_content.sh --selfcheck
ROADMAP §1 calls a macOS/ARM64 build *"the largest single gap"*, and STATUS recorded that no such binary exists. It does now — and building it was the easy half.
scripts/release.sh needed no change at all. The native leg already derives its name from uname -s/uname -m, and even carries a comment about touch -t working under both BSD and GNU. One command produced dist/tycho-v0.8.5-darwin-arm64.tar.gz, it passed release.sh's own smoke test (the packaged compiler builds and runs a core:strings program with no TYCHO_CORELIB), and make release-check built it twice, byte-identical. The port was done; only the packaging was missing, exactly as the roadmap predicted.
Then the gate that judges an archive did nothing.
$ make release-content
SKIP release-content: neither wine64 nor wine on PATH
scripts/release_content.sh exits at the first missing Windows tool — wine, mingw, objdump — and those tests sat above check_native. The native and mingw legs are independent and were gated together, so on every Mac and most Linux boxes the native tarball received zero content checks while the lane printed a tidy SKIP and make ci stayed green. That is the shape the first macOS artifact would have shipped in: built, smoke-tested by the script that built it, and unexamined by the gate whose entire job is examining it.
Each leg now states its own prerequisites. The native leg needs nothing a build host lacks and always runs; the mingw leg skips by name, and the summary says what it did not do rather than reporting ok for work it never did:
release-content: 31 legs over the NATIVE archive only, 0 failed
-- the Windows archive was NOT checked (x86_64-w64-mingw32-gcc not on PATH)
The glibc floor has no Darwin analogue, and skipping it was not the answer. check_native_glibc reads versioned GLIBC_x.y symbols. A Mach-O binary has none, so glibc_floor returns none and the leg would have printed *"needs no versioned glibc symbol (statically linked)"* — a sentence false twice over, since macOS has no static libc and these binaries link libSystem dynamically. A leg that cannot fail on a platform is worse than no leg, because in the log it reads exactly like one that passed. It is replaced on Darwin, not skipped:
| leg | what it catches |
|---|---|
| architecture | an x86-64 binary in an -arm64 archive — Rosetta if installed, nothing if not |
| only system libraries | the classic macOS packaging failure: the build box has Homebrew, the binary picks up /opt/homebrew/lib/..., and the tarball works everywhere except off the build box |
| minimum macOS | minos is the true floor here as GLIBC_x.y is on Linux — reported, not gated, because this project has no macOS deployment-target policy and inventing a ceiling would assert a decision nobody made |
All three are controlled, because three legs that have only ever been seen passing are indistinguishable from three that cannot fail — which is what check_native_glibc was on this platform. --selfcheck now drives them against binaries built to break them: an x86_64 probe for the first, a probe linked against a real Homebrew dylib for the second, and the unmutated archive for the control's control. 3 ok, 0 failed.
What this does NOT do. It does not publish anything; gh release create is still the owner's to run. It does not check the archive on a *different* Mac, so "runs on the build box" is what is proven and "runs on macOS 26" is not — which is exactly why minos is reported in the log where a human can read it. And the Windows archive remains unchecked from here, by name, which is the honest state rather than a silent one.
Cost: 3.5s, inside lane [2f/13], which previously did nothing on this host.
110. aarch64 Linux was compile-only for a month; a VM on the Mac closed it in an hour, and caught me repeating 100 — FIXED 2026-09-19
Pinned-by: grep -q '^builtin-qualified: tychoc tychoc1' Makefile Pinned-by: make builtin-qualified
ROADMAP §1 said of the cross-compiled targets: *"none of those binaries has been RUN. There is no Darwin or ARM machine here and no qemu [...] What is needed is a real machine, not a port."* Entry 103 retired half of that sentence with a real Mac. This retires the rest without one: an Apple Silicon box runs aarch64 Linux in a VM at native speed, which is a real machine by every measure that sentence cared about.
One Homebrew formula (lima), no Docker, no daemon, no account. Ubuntu 26.04, glibc 2.43, 8 cores, Apple's Virtualization framework:
make tychoc | 3.7s, ELF aarch64, runs, reports 0.8.5 |
make tychoc1 | 51s, ELF aarch64 static-pie |
make test | 1065/1065 |
tychoc1 linking static-pie is worth recording on its own: that is the flag clang silently drops on macOS, so the Linux archive's "no versioned GLIBC symbol" property — the thing 109 had to replace on Darwin — does hold on ARM64 Linux. A Graviton artifact would carry the same guarantee the x86-64 one does.
And the fresh clone immediately caught something the dev machine could not. make ci died at lane [1d4/13]:
builtin-qualified: FAILED (42)
FAIL args: the two compilers disagree
tychoc : error: 'args' is a builtin, not a member of package 'strings' ...
tychoc1:
tychoc1 said nothing, because it was not built. The target read:
builtin-qualified:
@sh scripts/builtin_qualified.sh
No prerequisites. The script compares the two compilers, so with either missing it compares a message against an empty string and calls it a disagreement — 42 of 42, including the control.
That is entry 100, committed by the commit that closed entry 100. 100 is docs-fences depending on an undeclared ./tycho; this is builtin-qualified depending on an undeclared ./tychoc1, written hours later, one lane over, by someone who had just finished writing the paragraph explaining why that is a defect. It passed everywhere it was tried because both binaries were lying around from earlier work — which is the entire failure mode, and exactly what a machine with nothing left over is for.
Declared, and verified the way the VM found it: rm -f tychoc tychoc1 && make builtin-qualified now builds both and passes.
The recurring shape, now on its fourth instance. 96 (work done, entry never updated), 102 (fix applied to one of two sites), 107 (branch existed, asked the wrong question), and this. None is ignorance; all four are a correct understanding applied incompletely. The sweep for more of these is the open question this entry leaves.
111. make test has an undocumented OS prerequisite, and fails loudly without it — 2026-09-19
Pinned-by: grep -q 'locale-gen' README.md
The first make test on the aarch64 VM came back 1063 passed, 2 failed:
FAIL float_lit_locale hostile=1 expected, got 0
FAIL float_str_locale hostile=1 expected, got 0
Both fixtures call runtime/tycho_rt.c@tycho_test_make_locale_hostile, which walks a candidate list — da_DK, de_DE, fr_FR, and the Windows spellings — looking for a locale whose decimal_point is not .. The point of the fixture is that Tycho's float formatting does not move when the C locale does, and it cannot demonstrate that without a comma-decimal locale to move it to.
A minimal Ubuntu image ships C, C.utf8, POSIX and en_US.utf8 and nothing else. No candidate matched, the helper returned 0, and the golden's hostile=1 failed.
This is not a defect, and the fixture is right. Returning 0 and failing is the correct answer: the alternative is a test that passes while proving nothing, which is the thing this repository is most consistently against. locale-gen da_DK.UTF-8 and the suite is 1065/1065.
What IS a defect is that nothing said so. make test is the first command in CONTRIBUTING's flow, and a fresh clone on a minimal distro — a container, a CI image, a new laptop — fails two fixtures with a message that names neither the locale nor the cure. The README's platform notes list xcode-select for macOS and MSYS2 for Windows and said nothing about this. They do now.
Left as REPORTED rather than gated: a lane asserting a comma-decimal locale exists would be asserting the developer's OS setup, not the tree, and the fixture already fails loudly and specifically enough to be diagnosable once the reader knows what hostile means.
112. The C compiler decided how many times to round, and the answer changed with the architecture — FIXED 2026-09-19
Pinned-by: grep -q 'ffp-contract=off' compiler/driver/driver.ty
The first make ci on aarch64 Linux (110) failed sheet-check:
FAIL: nearest: expected '0.1+0.2 0.30000000000000004'
-- render() returned a decimal that reads back but is not the nearest
near 0.1+0.2 0.30000000000000006
Every x86-64 run for months had said ...004. The aarch64 one said ...006, and the min subnormal came out 2.2250738585072012e-308 against ...14e-308. Same source, same flags, same compiler version — different answers.
The cause is one flag nobody set. Tycho's emitted C is compiled with -O3 -fwrapv -pthread and no -ffp-contract, so the C compiler is free to fuse a*b + c into a single FMA, rounding once where the source says twice. For ordinary arithmetic that is a better answer. For the algorithms that exist to *recover* rounding error it is a wrong one, and tools/tycho-sheet/cell/dtoa.ty@_two_prod is exactly that — a Dekker two-product:
(p, ((ah * bh - p) + ah * bl + al * bh) + al * bl)
ah*bh - p is there to capture precisely what a*b lost. Contract it and it evaluates to something else, the error term is wrong, and the exactness trick silently stops working. The digits it then generates still *parse back* to the right double — which is why nothing crashed — they are simply not the nearest ones, and render() is documented to produce the shortest nearest decimal.
It bites by ARCHITECTURE, which is the worst way to find out. ARM always has FMA, so gcc contracts there; baseline x86-64 has none, so it cannot, whatever the flag says. A defect that is invisible on the only machine the project was ever gated on is not a defect anyone was going to notice.
0.1+0.2 renders | |
|---|---|
| x86-64 Linux, every run for months | 0.30000000000000004 |
| aarch64 Linux, first run ever | 0.30000000000000006 |
aarch64 Linux, -ffp-contract=off | 0.30000000000000004 |
Proved before it was fixed: TYCHO_CFLAGS=-ffp-contract=off make sheet-check turned the lane green on aarch64, all 98411 generated floats included, with no source change at all.
Why this is a language fix and not a sheet fix. dtoa.ty is written in Tycho, and nothing in it is wrong. Any Tycho program doing compensated arithmetic — Kahan summation, two-sum, a correctly-rounded conversion — has the same hole, and would find it the same way: by being run on hardware the author does not own. A language whose fixture goldens are byte-identical, whose benchmark table says *"same checksum computed by every binary"*, and whose make test compares output to recorded text cannot let the C compiler choose the rounding. Both compilers now pass -ffp-contract=off.
What it costs, stated plainly: FMA is given up in hot float loops, and some numeric code will be measurably slower. That is the price of the same program producing the same floats on every host, which is the property this project sells.
What found it: not a fuzzer, not a sanitizer, not review — a second architecture. make ci is green on both, and neither machine alone could have told the difference.
113. x86_64-macos was the last compile-only target — 2026-09-19
Pinned-by: none -- the claim is that binaries RAN on a host; a lane cannot assert an architecture this repo's gate does not run on. Recorded with its method so it can be repeated.
103 retired *"none of those binaries has been RUN"* for aarch64-macos; 110 retired it for aarch64-linux. This is the third and last of the targets zig cc was only ever known to *compile* for.
Method, on the same Apple Silicon box: build the bootstrap compiler with CC="clang -arch x86_64", run it under Rosetta, have it emit C, compile that C natively with -arch x86_64, run the result and compare to the fixture's golden.
x86_64-macos fixtures: 288 match golden, 0 differ, 1 skipped
The skip is io_builtins, which reads stdin the probe does not feed — not a failure, and chased down rather than left as a number.
One real limitation, and it is Rosetta's, not Tycho's. The x86_64 compiler cannot spawn the C compiler itself:
xcrun: error: unable to load libxcrun ... (have 'arm64,arm64e', need 'x86_64')
An x86_64 process gets x86_64 children, and this machine's Command Line Tools ship arm64-only. On a real Intel Mac clang is x86_64 native and this does not arise. So what is proven is that the compiler's output is correct for x86_64 macOS and those binaries run; what is not is the driver's cc subprocess on *this* box, for a reason that has nothing to do with the port. Saying which is which is the point of the entry.
114. Four conc fixtures SIGILL under TSan on aarch64 — ROOT-CAUSED 2026-09-19: TSan's shadow layout, not Tycho
Pinned-by: none -- the subject is a sanitizer's behaviour on one host, which no lane in this tree can assert. Recorded with its reproduction and its evidence.
Found by the first full make ci on aarch64 Linux (110). chan, chancap1, parfor_chan and parfor_width failed, tsan variant only, with exit 132 (128+SIGILL) and no TSan report at all.
Reduced to 20 lines, deterministic. The boundary is exactly one spawned task:
| program | under TSan |
|---|---|
| a Tycho program with no concurrency | exit 0 |
two spawns with no channel | exit 0 |
| one spawned task on a channel, main on the other end | exit 0 |
| two spawned tasks sharing a channel | SIGILL, 3 of 3 |
TSan says what is wrong itself, once stderr is not thrown away — TSAN_OPTIONS=verbosity=1 on the failing case:
WARNING: ThreadSanitizer: memory layout is incompatible, possibly due to
high-entropy ASLR. Re-execing with fixed virtual address space.
***** Running under ThreadSanitizer v3 *****
Illegal instruction
The passing one-task case prints no such warning. TSan cannot map its shadow memory once the second task's arena is allocated, re-execs to fix it, and dies anyway.
Not Tycho's defect, on the evidence:
- the same emitted C, built without a sanitizer, prints the right answer and exits 0;
- the ASan variants of all four fixtures pass on this host;
- x86-64 Linux runs the tsan variant green;
make testis 1065/1065 on aarch64;- the runtime calls
mmapnowhere and sets no custom thread stack size — arenas aremalloc, so there is no exotic mapping to blame it for.
The obvious fix does not work. Ubuntu's documented sanitizer workaround is to lower ASLR entropy; vm.mmap_rnd_bits at 28, 24, 18 and 16 all still SIGILL. So it is not simply entropy — it is TSan v3's shadow layout against this kernel (7.0.0 aarch64) under Apple's virtualization.
Scope of the claim, stated because it matters. This was measured in a VM on Apple Silicon. Whether real aarch64 Linux hardware behaves the same is untested here, and the honest reading is "TSan's shadow mapping fails on *this* host", not "TSan is broken on ARM".
What it costs. The aarch64 gate runs 46 of 48 conc fixtures and loses the TSan variant of four. That is the same shape as macOS losing LeakSanitizer (109): a sanitizer absent on one platform, named rather than silently skipped, with Linux/x86-64 remaining the host that scores it.
115. The fail-open audit reached three parsers and missed a fourth, on the untrusted side — FIXED 2026-09-19
Pinned-by: make weblog Pinned-by: grep -q 'strings.parse_int(bytes_s)' examples/weblog/main.ty Pinned-by: sh -c '! grep -q "strings.parse_int_prefix(bytes_s)" examples/weblog/main.ty'
Found by auditing the shape that produced four findings in one day — a fix understood, written down, and applied to *some* of the sites it covers (96, 102, 107, 110). The candidate here was the ROADMAP's own fail-open sweep: *"every corelib parse function and every caller that reads persisted or wire data, checked for a parser that returns a plausible wrong value instead of an error."* It fixed three. This is a fourth.
examples/weblog/main.ty parsed the byte count of an HTTP access-log line with the unchecked strings.parse_int, which fails open in three different ways:
| input | parse_int returns |
|---|---|
"abc" | 0 — indistinguishable from a real zero |
"9999999999999999999" | 0 — an overflow becomes a plausible count |
"50x" | 50 — it takes the numeric prefix and discards the rest |
Measured end-to-end on a crafted log before the fix — both records accepted, carrying invented numbers into the report:
hits bytes url
1 100 /good
1 0 /over <- 9999999999999999999
1 50 /junk <- "50x"
What makes it worth an entry is which side was checked. Twelve lines above, the CLF timestamp is parsed and validated, and a bad one fails the record. Forty lines below, the --top CLI flag — a number the operator typed — uses strings.parse_int_checked. So the trusted input was checked and the attacker-influenceable one was not, in the same function, by the same author, on the same day. That is not ignorance about the API; it is the convention being applied unevenly, which is this file's most repeated finding.
Fixed with parse_int_checked, failing the record exactly as the timestamp does. - is handled explicitly, because CLF spells "no body sent" that way: it maps to 0 deliberately instead of arriving there through the same fail-open path that swallowed "abc" — the distinction the bare parse could not make.
The golden could not have caught this and still cannot, which is why the lane gained a leg rather than a fixture: examples/weblog/access.log contains no malformed byte count, so weblog: ok (tychoc == golden) was true with the field unchecked. examples/weblog/run.sh now feeds a crafted log and requires /over and /junk to be rejected, /good to survive, and /nobody (bytes -) to count as 0. Controlled: with the fail-open parse restored the lane names both accepted records and exits 1.
The sweep also cleared four parsers it suspected, which is worth recording so the next reader does not redo it: datetime.parse_iso/parse_iso_tz/ parse_clf/parse_clf_tz return a bare DateTime with no Result, but they fail closed to a -1 year sentinel, datetime.ok() is the documented test, and both production callers — server/main.ty@parse_http_date and this one — call it. The residual Err(e): return Err(e) count from the or_return migration is 0.
116. A plausible typo in a post's date rendered as 1970 and sorted first — FIXED 2026-09-19
Pinned-by: make site Pinned-by: grep -q 'strings.parse_int(draw)' examples/site/main.ty
The second site the 115 sweep turned up. examples/site/main.ty read a post's date: front matter with the unchecked strings.parse_int, and that value does two jobs: it sorts the index (sort.argsort(dates)) and it is rendered through datetime.format_iso(datetime.from_unix(...)).
parse_int fails open by taking a numeric prefix, so the most plausible mistake an author can make — writing an ISO date where a unix timestamp belongs — produced a real-looking wrong one:
date: 1700000000 -> 1700000000 renders 2023-11-14T22:13:20 (correct)
date: 2024-01-15 -> 2024 renders 1970-01-01T00:33:44
date: Jan 15 2024 -> 0 renders 1970-01-01T00:00:00
date: -> 0 renders 1970-01-01T00:00:00
The 2024-01-15 row is the one that matters. It is not a crash and not an obviously empty value — it is a date, on a finished-looking page, wrong by 54 years, and the post silently sorts first as the oldest.
It dies now, naming the file and the cure, rather than publishing it:
site: hello-tycho.md: `date:` wants a unix timestamp, got: 2024-01-15
A generator that emits a page with a fabricated date is worse than one that stops, because nothing downstream will ever question it.
Same gate-blindness as 115, same remedy. Every date in the shipped content is a valid timestamp, so site: green [...] matches golden was true with the field unchecked. examples/site/run.sh now copies the site, rewrites one post's date to 2024-01-15, and requires the build to fail with the message that names the cure. Controlled: with the fail-open parse restored the leg reports a malformed \date:\ was ACCEPTED and the lane exits 1.
Where the sweep found nothing, recorded so it is not redone. Of ~50 unchecked parse_int/parse_float call sites, most are CLI arguments or test fixtures. The ones reading real data were checked individually and are sound:
| site | guard |
|---|---|
server/main.ty@parse_pos | is_digits and a length cap to HUGE_POS — it handles the overflow case explicitly |
server/main.ty@as_uint | is_digits, dies otherwise |
tools/tycho-q/main.ty | a round trip: str(n) == cell, which rejects 007, -0 and overflow alike — stricter than _checked |
corelib/toml, corelib/json, tycho-sheet, tycho-ledger | already match on a Result |
tycho-q's round trip is the best guard in the tree for this and is worth copying: it does not ask whether the parse succeeded, it asks whether the parse is reversible, which is the only test that catches a silently-normalised value.
117. The NUL sweep reached six packages and missed the one where the string is a security decision — FIXED 2026-09-19
Pinned-by: make tls-verify Pinned-by: grep -q '_has_nul(host)' corelib/tls/tls.ty
Third find of the "applied to some of the sites it covers" audit, and the worst of the three.
Entries 75-78 swept the corelib for strings crossing into C where an interior NUL truncates them, and fixed a password, a URL and a TZ string. Checking where that sweep landed: core:net refuses with a named error (BadAddr — *"NOT Failed: no syscall was made, so retrying cannot help"*), and core:http, core:io, core:os, core:path and core:datetime all guard, the last two with inline loops rather than a shared helper. core:tls did not.
fn connect(host: string, port: int) -> ptr:
return tlsx_connect(host, port) # host straight through
Why this package is the one that mattered. The shim hands host to three different consumers:
| line | consumer | what a truncation changes |
|---|---|---|
tcp_connect(host, port, ms) | the TCP target | which machine is dialled |
SSL_set_tlsext_host_name(ssl, host) | SNI | which certificate the server offers |
SSL_set1_host(ssl, host) | verification | which name the cert is checked against |
So "good.example.com\0anything" connects to, announces, and verifies good.example.com, while every check the *caller* performs — an allowlist, a policy, an audit log — sees the whole string. The two disagree, and the one that decides security is the truncated one. In the package whose header promises *"secure by default [...] fail closed, so you never get an insecure connection by accident."*
Guarded now, failing closed to a null handle, which is the documented failure this package already has.
Gated with a leg that can actually tell the difference, which took some care: the obvious test — connect to 127.0.0.1\0junk and expect refusal — proves nothing, because a truncated 127.0.0.1 connect fails too. scripts/tls_verify.sh already stands up a real TLS server with a real CA, and its leg [2] proves that localhost is accepted by it. So leg [4] asks the probe to build "localhost" + chr(0) + "evil.example.com" itself — argv cannot carry a NUL — against that same server. With the guard it is refused; without it, it comes back OK, and the leg reports:
LEAK: an interior NUL was TRUNCATED -- core:tls connected to, announced
and verified a name the caller did not pass.
Controlled both ways: removing the guard reddens make tls-verify; restoring it goes green. corelib/test/tls also gained a nul_host_refused=1 assertion, though that one is documentation rather than a discriminator, and the entry says so rather than letting it look like proof.
The pattern, sixth instance in a day. 96, 102, 107, 110, 115/116, and this. Every one is a correct understanding applied to a subset of its sites, and this is the first where the missed site was the security-critical one — which is the argument for auditing by *convention* rather than waiting for the next bug report.
118. The catalogue gate reported 431/431 by matching substrings; 11 were never named — FIXED 2026-09-19
Pinned-by: python3 scripts/corelib_doc_check.py --selfcheck Pinned-by: grep -q 'def named(name, cat)' scripts/corelib_doc_check.py Pinned-by: python3 scripts/corelib_doc_check.py
99 created scripts/corelib_doc_check.py that morning so that absence from the catalogue could not hide, on the reasoning that *"nobody audits an inventory they cannot diff."* It reported 431 of 431. It was matching bare substrings:
if name in cat: # `place` is "documented" -- by the word *replace*
continue
Every short function name passes on any longer word that contains it:
| function | passed because the catalogue contains |
|---|---|
toml.place | *replace* |
sha256.ch | *search* |
json.esc, markdown.esc | *__esc__ape* |
sort.asc / sort.desc | *__asc__ending* / *__desc__ending* |
bignum.strip | *__strip__ped* |
datetime.bad | *__bad__ request* |
pool.pack, toml.d2, sha256.maj | likewise |
So 431/431 meant 420 genuinely named and 11 the catalogue never mentions — a gate reporting complete coverage of an inventory it had not actually checked, which is the exact failure it was written to prevent.
The selfcheck could not have caught it, because it used the same matcher. It asserted "a documented name passes, an absent one is reported" with n not in cat — so it tested substring behaviour against substring behaviour and agreed with itself. A selfcheck sharing the bug it polices is worse than none: it converts an unexamined assumption into a printed ok.
Both now go through one named() function, and the selfcheck carries five legs that are real names which really passed vacuously — place against *replace*, ch against *search*, esc against *escape*, asc against *ascending* — plus one that must still match at a boundary, so the fix cannot overshoot into rejecting everything.
All 11 were resolved rather than the matcher alone, because a gate that now reddens is only useful if the thing it found gets fixed:
- 3 are real API and are now documented:
sort.asc(15 external qualified calls),sort.desc,markdown.esc. - 8 are internal helpers with zero external callers, privatised under the same two criteria 96 used — not called qualified from outside, and absent from the catalogue:
bignum._strip,datetime._bad,json._esc,pool._pack,sha256._ch,sha256._maj,toml._d2,toml._place.
The frozen surface goes 431 → 423, re-recorded in the same commit so the diff is one a reviewer can refuse, and make corelib is green at 46/46 after the renames.
Found by asking 94's question of the corelib. 94 established that a shipped *builtin* exercised by no fixture is worth finding, and checked all 41. Nobody had asked it of the 431 corelib functions. The answer was 24 that no test, example or tool references — 20 of them exercised only indirectly through their own package, and the 4 with no caller anywhere (http.post_bytes, os.is_windows, path.last_slash, regex.group_end) are documented public API with no direct test, which is recorded here and not fixed: writing tests for them is real work, and inventing one to close an entry is how a suite fills with tests nobody chose.
119. The normative conformance table cites five "lanes" that nothing ran — FIXED for three, MEASURED for two
Pinned-by: grep -q 'make -s parity-fuzz' scripts/ci.sh Pinned-by: make parity-fuzz
docs/spec/appendix-e-conformance.md is how the spec claims to be backed by executable evidence: one row per clause, naming what proves it. Five rows name a lane:
| clause | cited as evidence |
|---|---|
§5.5, §9.3 — structural ==, functions not comparable | eqparity lane |
| §8.1 — literal adaptation to context type | typeparity lane |
§13.3 — unary - ~ not | unaryparity lane |
| §22 — parallel-for, channel-drain | parforparity lane |
None of them is a lane. fuzz/run_eqparity.py, run_unaryparity.py, run_parforparity.py, run_typeparity.py and run_pkg.py appear in neither the Makefile nor scripts/ci.sh nor any other script. Seven fuzz drivers exist; three are wired. The word "lane" is doing work in that table that nothing backs.
They are not rotted — they work, and they pass. Run by hand:
eq-parity: 512/512 composite/newtype ==,!= cases match the oracle
type-parity: 4608/4608 scalar binop cases match the `expect` oracle
unary-parity: 30/30 unary-operator cases match the oracle
parfor-parity: 25/25 parallel-for gate cases match the oracle
run_pkg: ok=20 skip=0 FAIL=0
That is the worst version of this, not the best. A rotted script announces itself the first time anyone runs it. These are correct, comprehensive differential fuzzers — they generate cases, compile with both compilers, and compare accept/reject and the emitted C against an oracle — sitting one line of Makefile away from being enforced, and nothing would have noticed the day they started failing.
Three are now lane [1d5/13], and the cost is why there was never an argument:
| fuzzer | time |
|---|---|
eqparity | 5s |
parforparity | 1s |
unaryparity | 0s |
| all three | 6s |
Six seconds on a ~300s gate. There was no budget objection to answer; there was simply never a target.
Two are left out deliberately, and this is a decision rather than an oversight. typeparity costs 65s and run_pkg.py 143s — +24% and +48% on the sweep. That is the same shape as parse-check in 106 and gets the same treatment: measured, written down, and the owner's call. §8.1's citation stays unbacked by any automatic run until that call is made, which is the honest state and is said here rather than left implied.
How it was found. Not by reading the fuzz directory — by asking why STATUS.md says the fuzzer finds nothing *"at 200 seeds"*, going to raise the count, and noticing on the way that ls fuzz/run_*.py had four more entries than the Makefile did. The pattern's tenth instance today, and the first where the uncovered thing was the spec's evidence rather than a gate's.
120. Appendix E's sub-case citations were unchecked — GATED 2026-09-19, and no defect found
Pinned-by: make spec-fast Pinned-by: grep -q 'Appendix E sub-case citation' scripts/spec_check.sh
The follow-up to 119: if the conformance table cited five lanes that did not exist, what else does it cite that nothing validates?
scripts/spec_check.sh already asserts that every fixture path in the table resolves — *"the conformance matrix is worthless if it points at fixtures that were renamed or removed."* Its regex allows five prefixes: tests/, reject/, abort/, corelib/test/ and examples/. Of 341 evidence tokens in the table, 316 are validated and 25 are not.
Almost all of the 25 are innocent. Most are prose quoted in the clause column — --help, \r, GREET, usage. Four were the lane names 119 dealt with. And ten are sub-case names, a form the checker cannot see:
| §5.2.6 | ... | `corelib/test/io` (`byte_index`, `byte_slice`, `byte_cat`, ...) |
| §14.3.1 | ... | `corelib/test/result` (`why`, `io_why`) |
| §24.1 | ... | `tests/ffi` (`ffi_sfold`, `ffi_spick`) |
The container is validated; the names inside the parentheses are not — and those names are the actual evidence for the clause. A renamed test case would leave the spec citing a name no file contains, and every gate would stay green.
Every one of them resolves today. byte_* are in corelib/test/io, io_why and why in corelib/test/result, ffi_sfold/ffi_spick/use_res_close in tests/ffi, log_safe in server/main.ty. This entry found no defect, and says so rather than dressing a latent gap as a discovery — a check added where nothing was broken is worth less than one added where something was, and pretending otherwise is how a log stops being readable.
What it closes is the *silence*: 12 sub-case citations are now asserted to appear inside the file they name, and the lane says how many it checked. Controlled — a renamed sub-case reddens make spec-fast and names both the missing case and its container:
spec-check: FAIL -- Appendix E cites `byte_index_RENAMED` inside
`corelib/test/io`, which does not contain it
One correction to my own method, recorded because it nearly produced a false finding. Four of these names came back "absent" on the first pass because I searched tests/ for them, while their rows plainly name corelib/test/io and server/main.ty as the container. The citations were right and the search was wrong. A sweep that reports a finding without reading the row it came from is generating noise, not evidence.
121. A wavefront proved by a stopwatch: make-check's race leg flaked a second time — FIXED 2026-09-19
Pinned-by: make make-check Pinned-by: grep -q '_race_attempts=3' tools/tycho-make/run.sh
make ci went red on make-check, and green on three runs of the lane alone, and on six under a 12-way load:
FAIL: race: c2 started at trace line 9, AFTER the wide level had entirely
finished at 8 -- a node is waiting for its whole level
Leg [8b] separates a wavefront scheduler from a level-synchronised one. race.mk puts three one-second sleepers and a millisecond-long chain behind one source; if c2 — which names no sleeper — starts before the sleepers finish, nothing is imposing a level barrier. The margin is a full second against a chain that takes milliseconds.
It had already been weakened once for this. The code carries the note: the assertion first compared against the *first* end w, failed under CI load, and was moved to the *last* one. It failed again anyway.
A wider margin cannot fix it, and that is the point. The overlap is an *observation*, not a guarantee: under a full sweep — a dozen lanes plus this build's own TYCHO_THREADS=8 — the chain can simply not be scheduled for a second, and then c2 starts late with no barrier present. A starved thread can be starved arbitrarily long, so no constant makes this sound. The project already knows this: 86 is excused from pinning on the grounds that *"a gate asserting a timing is a coin toss"*, and 108 was the same mistake in core:net's tests this morning.
What is sound is the asymmetry. If a barrier exists, no run can show the overlap. If none exists, a run that gets CPU will. So the leg now observes up to three times and requires one success — which turns a coin toss into a property test without weakening what it proves.
Controlled against a real barrier, which is the leg that matters: giving c2 a genuine dependency on the whole wide level (c2: c1 w1 w2 w3 — exactly what a level-synchronised scheduler imposes) fails all three attempts and reports
race: in 3 attempts c2 never started before the wide level finished
so the retry did not make the assertion unfalsifiable. Restoring the fixture goes green.
Third timing-shaped flake found by running the sweep on a second machine, and the pattern across 102, 108 and this one is worth stating: all three were written as *orderings* — "read to EOF", "retry for arrival", "c2 before the level" — and all three were implemented as something that only *usually* observes the ordering. The property was right each time; the instrument was a stopwatch.
Found by two agent probes in one afternoon, 2026-09-21 (head 88b37681)
Two probes run back to back per probe-procedure.md, aimed at the two surfaces ROADMAP §1 had least outside contact with: handle as a subject in its own right, and the four layout/SIMD features. Records: handle, layout.
22 findings between them. Nine were fixed the same day across four commits, a fifth commit fixed the watchdog defect the setup exposed, and one entry below is a probe's finding being refuted — the first time that has happened against a measurement rather than a citation, and the reason this section exists rather than five commit messages. The residue is 128.
122. The probe watchdog scored every macOS probe as finished — FIXED 2026-09-21
Pinned-by: grep -q 'ps -o time=' scripts/probe_run.sh Pinned-by: sh scripts/probe_run.sh --selfcheck
scripts/probe_run.sh decides a probe is done on two signals — zero CPU-time delta and no write under the probe directory — and both were spelled for Linux only. On this Mac they do not fail loudly, they go constant:
awk '{print $14 + $15}' /proc/$$/stat -> -1 (no /proc, the || fires)
find . -type f -newermt "@0" -printf -> "" (BSD find has neither)
A constant is a zero delta, so both halves of the quiescence test are satisfied on the first window: the watchdog kills the agent after QUIET seconds, prints *"quiet for 300s (no CPU, no writes) — treating as finished"*, and returns 0. Every probe run on a Mac would have been truncated at five minutes and recorded as a clean exit. The script's own header says the reason for scoring both signals is that either alone is "a false positive waiting to happen"; on this host both were the same false positive.
The fix picks the spelling from the host: /proc where it exists, ps -o time= plus stat -f %m otherwise.
The defect was one command away the whole time. --selfcheck builds a wedged process and a working one and requires the watchdog to tell them apart; it fails on the parent commit on this host. It is the same shape as 104 and 106: a check that exists, works, and is attached to no verdict. The pin above is now that verdict. It was left out of make ci on the grounds that it sleeps through two controls; measured here at 9.2s, which is small enough that the cost argument no longer holds and wiring it is a one-line decision.
123. An opener's result that is never bound was never freed — FIXED 2026-09-21
Pinned-by: test -f tests/reject/handle_unbound_stmt.ty Pinned-by: test -f tests/reject/handle_unbound_arg.ty Pinned-by: grep -q 'hdl_decl' compiler/types/tcheck.ty Pinned-by: make test
Tycho makes an unused int a hard compile error — 'x' declared and not used — and an unused import an error too. It accepted a leaked OS resource. The probe reproduced it with a counting shim before anything was changed, five calls of each shape:
h := open("a") opens 5 closes 5
use(open("a")) opens 10 closes 5
open("a") opens 15 closes 5
A handle is freed by its owning variable's scope exit and by nothing else, so a handle-typed call in any other position — a bare statement, an argument, an operand — opens a resource with no owner and leaks it for the life of the process. No error, no warning, and nothing in docs/ said so: the probe could only learn it by adding counters to its own shim and measuring. Its report lists this under *"what I could not learn from the documentation"* and calls it "the one thing about this feature I would most want written down".
The rule is now what the existing copy diagnostic already advised — that message has always said *"bind the opener directly (f := open(...))"* — using the same marker discipline channel(...) has used since CC-4: S_DECL marks the one sanctioned position and every other position lands on the reject.
close(open(p)) is deliberately not caught: it does not leak, and it is already refused one line further on with a message that is the useful one there. Getting that wrong is what the check's first run did.
Both compilers, because they are held to agreeing, and on first build they diverged — the reference rejected and the self-hosted one emitted C. The self-hosted rule is hdl_decl in compiler/types/tcheck.ty, the twin of the chan_decl flag beside it, captured and cleared at the top of _ex2 so only the direct rhs of a declaration is sanctioned.
The spec was wrong in a second place in the same area and is corrected in the same commit: §25 said *"reassigning a handle variable frees the previous handle first"*. It does not — a handle variable cannot be reassigned is a compile error, and docs/reference/ffi.md said so correctly all along. The probe had planned its walker around reassignment and backed it out.
124. The over-wide vector lint named an ISA the host is not, and prescribed a flag cc rejects — FIXED 2026-09-21
Pinned-by: grep -q 'use a narrower vector, or on x86-64 build' compiler/types/zsema.ty Pinned-by: make vector-check
On the aarch64 Mac — a shipped platform, green in make ci since 2026-09-19 — declaring a wide vector said this:
warning: `vector[4]int` is 32 bytes but x86-64 baseline holds 16, so every
operation on it splits -- build with `--target x86-64-v3`
Taking its advice, on the machine it had just been printed on:
tychoc: --target: cc does not accept -march=x86-64-v3
--target's only values are x86-64-v2/v3/v4 and baseline; there is no aarch64 spelling. So the lint fired on this host, about a host this is not, and named the one remedy that cannot be taken here. It fires on everything wider than 16 bytes, which is most of the reason to reach for a vector at all.
Two thirds of the message were right. target_vec_bytes() never consults the host: 16 is a static assumption about a baseline, correct for SSE and for NEON alike, so the byte figures were never wrong. The defects were the label, which asserted a machine, and the remedy, which was ISA-specific and offered unconditionally. Narrowing now leads because it works on every target; a level the user actually asked for is still named as given. --native is deliberately not offered — it does not make the value fit, it makes the width unknowable and so turns the lint off.
The gate already knew. scripts/vector_check.sh:266 skips its leg [7] with the reason "x86-64 host only"; the diagnostic it was checking did not.
125. Three things the handle documentation said that are not true — FIXED 2026-09-21
Pinned-by: grep -q 'header over an indented' docs/spec/14-ffi.md Pinned-by: grep -q 'handle -> bool' docs/reference/builtins.md Pinned-by: grep -q 'tycho-fh' docs/reference/ffi.md Pinned-by: make docs-fences
All three found by the handle probe, each measured before it was changed.
1. The spec's own spelling does not parse. §25 opened with "A handle Name: free: c_free declares a nominal, affine, opaque C resource". Typed as written:
p18.ty:1: error: expected newline
1 | handle Dir: free: dw_close
| ^
It is a header over an indented free: line — a block — as appendix-a-grammar.md:85 and docs/reference/ffi.md both say correctly. This was the first thing the probe wrote and the first thing that failed. The replacement is a compiled fence, not prose: make docs-fences builds and runs every Tycho fence, so the syntax cannot drift out of the spec again without reddening a lane. Prose is what let it drift.
2. The worked handle example contains no handle. docs/reference/ffi.md sent the reader to examples/sqlite/ three times for handles — "opaque db/stmt handles" — and examples/sqlite/demo.ty declares none: db and stmt are raw ptr and sqlite3_close is called by hand. grep '^handle ' over every .ty in the distribution returns tools/tycho-fh/main.ty, which those pages never mentioned. Nothing the FFI documentation pointed at demonstrated the feature it was pointing at it for. Both sentences carrying this also linked docs/reference/ffi.md *from* docs/reference/ffi.md as though it were another document; the probe went looking for the other page.
3. is_null on a handle was undocumented where it counts, and the one shipped handle program said it was impossible. The overload appeared in exactly one sentence of one page; both normative tables gave ptr -> bool only. Against that, tools/tycho-fh/fh.c ships fh_ok with the comment *"A handle cannot be tested against null in Tycho … so asking C is the only way"*. Measured: is_null(h) is false after a good open, true after a failed one and after close(h). The probe believed the running code over the one true sentence, wrote a redundant _ok shim into its own binding, then measured and deleted it. The same file also claimed the destructor receives NULL; it does not — the compiler emits if (h) fh_close(h); and nulls the variable on an early close, and a failed open gives opens=0 closes=0.
The finding is not the three sentences. It is that a first contact could not learn this feature from the documentation and had to learn it from generated C.
126. Nothing ever checked that a handle's free: names its destructor — FIXED 2026-09-21
Pinned-by: test -f tests/reject/handle_free_undeclared.ty Pinned-by: test -f tests/reject/handle_free_wrong_type.ty Pinned-by: make test
Two mistakes Tycho had no opinion about. Misspell the destructor and it reached cc:
a.c:3224:14: error: call to undeclared function 'hv_shut'
3224 | if (h_h) hv_shut(h_h);
tychoc: C compilation failed (cc -O3 -fwrapv ...)
A generated file, a generated line, and no mention of the .ty line where the handle is declared — while every other mistake in this compiler gets a Tycho line and a caret. Declare it at the wrong type — extern fn hv_close(d: ptr) for a handle H — and it compiled silently: nothing compared the destructor's parameter to the handle it frees, and the emitted if (h_h) hv_close(h_h); type-checks in C either way, because a handle is a void* down there. The type hole is invisible by construction — C cannot catch it and Tycho was not looking.
The check runs in resolve_program, after every signature is registered rather than in parse_handle, because the destructor may be declared *below* the handle it frees — tests/ffi/main.ty does exactly that. parse_handle's own comment has said since it was written that the free fn is "normally an extern fn c_free_fn(h: Name)"; these two checks are what turn "normally" into the rule.
The oracle earned its keep here, and the record is worth more than the fix. The first tychoc1 version compared the destructor's parameter against the bare handle name, but a nominal type is encoded with a leading @, so it refused both of the tree's own correct handle programs. Every check run by hand passed: the two new reject fixtures failed correctly and the positive case, written in one file with no package prefix, still built. make parse-check found it in one run, three ways at once — leg13 as AFFINE-WRONGLY-REFUSED, leg10 as two TYPE-DISAGREE rows naming tychoc=ACCEPT tychoc1=reject. That is the differential instrument src/tychoc.c is kept for, doing its job against a change whose own author had tested it.
127. A probe measured its own transcription error and filed it as a layout defect — CLOSED 2026-09-22: refuted by measurement
Pinned-by: grep -q '__attribute__((packed))' compiler/emit/emit.ty Pinned-by: make test
The layout probe's finding 2, which it introduced as "the finding I would most want a Tycho maintainer to see": §17.1a states a packed struct has no padding and each field at the sum of the sizes before it, and the emitted C carries no __attribute__((packed)) — cc measuring it reports sizeof=20 alignof=2 with width at 14, against the 18/12 the spec promises. A normative claim contradicted by the compiler's own output, with an FFI consequence: a packed struct handed to a C function expecting a packed layout.
It does not reproduce. tychoc --emit-c on the probe's own main.ty, run here, ends that struct with } __attribute__((packed));, and cc measuring that definition verbatim reports:
emitted-verbatim TgaHeader: sizeof=18 alignof=1 offsetof width=12 height=14 descriptor=17
which is §17.1a exactly — and exactly the figures the report called "byte-exact would be". The attribute has been emitted since packed shipped (5fff6d5c).
The defect is in the probe's apparatus. probe/layout.c, whose header says the definitions were "copied VERBATIM out of tychoc --emit-c", carries the attribute on its align(8) struct and not on the packed one. The copy dropped one line, cc faithfully measured the mis-copy, and the number came back as a language defect. Every downstream sentence — the 20 bytes, "what packed actually buys is the side-table", the §14 worry — describes probe/layout.c.
Why this is an entry and not a deleted paragraph. probe-simd-2026-09-07.md already recorded that a report's citations must be opened rather than trusted, after an agent reported a doc as wrong that was right two lines from where it looked. This is the stronger form: a report's own measurements have to be re-run, because a probe that builds an independent instrument can be wrong about the instrument — and it will present that error in the most convincing format available, a number from a second toolchain against a quoted spec line. The procedure's "check each finding against main before acting" step is what caught it, one day late.
128. The residue: fourteen findings from 2026-09-21 that nothing has acted on — FIXED 2026-09-24
Pinned-by: grep -q 'The strip also guarantees one class of false finding' docs/internals/probe-procedure.md Pinned-by: grep -q 'Write down which one you used' docs/internals/probe-procedure.md
2026-09-24: eleven acted on, marked in the table; the handle close/is_null row since, as #130; layout 4 by tools/tycho-grade/, which found #131 on the way in; the design half of layout 1 by size_of$ on an align(N) struct; that of layout 6 as #132. Nothing in the table is open, and the two procedure defects at the end of this entry are fixed in probe-procedure.md; the rows with their own entries (#129-#132) carry their own pins.
Listed rather than fixed, because they are doc-layer and design questions and the surface is frozen. Each is checked against main as of 2026-09-22.
| from | finding |
|---|---|
| handle 8 | FIXED 2026-09-24: a close(h) row in reference/builtins.md. close(h) on a handle is absent from reference/builtins.md, which lists only close(ch) for channels — and docs/README.md calls that page "the single answer layer" |
| handle 9 | FIXED 2026-09-24: stated in reference/basics.md and structs-tuples.md. the reserved set includes the type keywords, so bytes: int is not a legal field name; documented only in appendix B, named on no entry-path page |
| handle 10 | FIXED 2026-09-24: both compilers now name the tail-position rule; tests/reject/value_if_nested.ty. expected an expression on a nested value if never says the rule is *tail position only* |
| handle 13 | FIXED 2026-09-24: reference/ffi.md Linking now tables every flag --help lists. correct but unfindable: -> Option(string) on an extern, --print-shims, and that tychoc --help beats both FFI pages on the command line |
| handle 14 | FIXED 2026-09-24: reworded in spec §25 and reference/ffi.md. §25's scope-exit list — "block end, early return, break, continue, or_return" — reads as though a continue frees a handle owned by the enclosing scope. It does not; the list is about the owning scope |
| handle — | FIXED 2026-09-24, #130: a use after an unconditional close(h) is now a compile error in both compilers. after close(h), is_null cannot distinguish "closed" from "never opened", because close nulls the variable. Use-after-close is documented as permitted and is not compile-rejected, so the one remaining misuse of an exactly-once resource is undetectable in both directions. A design question |
| layout 1 | FIXED 2026-09-24: size_of$(T) also takes an align(N) struct, in both compilers; the value is implementation-defined (Appendix F) but always a multiple of N, and tests/align_struct.ty pins 8 4. No new name. size_of$ requires a packed struct and packed/align(N) are mutually exclusive, so align(N) is unobservable from inside the language. The probe had to emit C and run cc to learn its align(8) did anything |
| layout 3 | FIXED 2026-09-24: vector/swizzling in reference/arrays-slices.md, packed/align in structs-tuples.md, each with a run fence. vector[, align(, swizzling and packed appear nowhere in docs/reference/. A reader following README → tutorial → reference finishes believing Tycho has no SIMD type, no layout control and no swizzling |
| layout 4 | FIXED 2026-09-24: tools/tycho-grade/, a TGA/BMP/QOI colour grader gated by make grade-check, grades each pixel as one vector[4]f32 multiply-add, reorders BGRA↔RGBA with px.(r, b) = px.(b, r) and reads its header through a packed struct. align(N) is deliberately not used: it is unobservable inside the language (layout 1) and the program has no C boundary for it to matter at. vector[N]T, align(N) and swizzling have zero users in examples/ and corelib/; packed has four, and corelib/raster/raster.ty was singled out as the one teaching example any of the four has |
| layout 6 | FIXED 2026-09-24, #132: math.min/max/clamp are lane-wise at a vector[N]T in both compilers, one compare and blend per bound, and tools/tycho-grade clamps with one math.clamp. No new name, no signature change. no lane-wise min/max, and math.clamp refuses a vector (does not satisfy comparable(T)), so the clamp — what a grading kernel does as often as the multiply — drops out of the vector unit |
| layout 8a | FIXED 2026-09-24: §5.5, reference/basics.md and the diagnostic (both compilers) now carry the compiler's real rule. §5.5 omits u8 from the ordered scalars, the mixed-type diagnostic omits char/u32/u64/f32, and the compiler's real rule is in neither. Two documents, two different wrong answers |
| layout 8b | FIXED 2026-09-24: both stated, and exercised by the vector fence. str() on a vector works and is undocumented; a vector as a struct field works and is undocumented |
| layout 8c | FIXED 2026-09-24: an operator table in reference/types.md. bytes + and [a:b] are in the spec and absent from reference/types.md's bytes section — correct docs, wrong layer |
| both | FIXED 2026-09-24, #129. two -Wunused-value warnings out of core:strings' slice_bytes/slice_str land in every user's build, naming a generated line in a file that is then deleted. Both probes hit them independently and both spent time establishing they had not done something wrong; make emit-warn now fails on it, and on the two siblings the same sweep found (math.sign's z == z at T = int, ./tychoc1's ((k == q)) map probe) |
The shape of the residue is one finding. Nine of the fourteen are the same defect: the rule exists, the compiler is right, and the page a first contact actually reads does not carry it. docs/README.md promises reference/ is "the single answer layer" and routes spec/ for "the exact rule for an edge case"; four shipped language features and two builtin overloads live only on the far side of that split. That is a documentation-architecture question, and it is the thing two independent strangers hit hardest on the same afternoon.
Two procedure defects found the same way, and they belong here because they cost future rounds rather than this one:
- The setup strip deletes
docs/internals,docs/rfcandtests, so every probe will report the links into them as dead. Thehandleround filed six that are not. The deletion list should say so. pi-run.logrecords only a failed launch (Model "mimo-v2.5" is ambiguous across providers), so which model wrote either report is not recorded anywhere. The procedure has a whole section on choosing a model and no step that writes down the one used.
129. Every user's build drew two -Wunused-value warnings out of core:strings — FIXED 2026-09-24
Pinned-by: ! ( ./tychoc --emit-c tests/pkg/vendor_deps/main.ty | grep '= h_strings___slice_ok(' | grep -qv '((void)0); })' )
The both row of #128. slice_bytes and slice_str each open with _slice_ok(...) or_return on a Result(void, SliceErr), and src/tychoc.c emitted every or_return as a statement expression ending in _orN.okv — the ok payload — whether or not there was one. On a void result that tail is a placeholder nobody reads, so cc warned about it in every program that imported core:strings, naming a line in a generated file that is deleted after the build. Both probes hit it independently and both spent time establishing the warning was not theirs.
tychoc1 never had it: _orret in compiler/emit/emit.ty already ends a void payload with ((void)0). The reference compiler now does the same. The pin reads the emitted C for the two call sites rather than grepping the compiler source, and was run against the unfixed compiler first: it goes red there.
130. A use after close(h) compiled, and is_null could not tell closed from never opened — FIXED 2026-09-24
Pinned-by: ./tychoc --emit-c -o /dev/null tests/reject/handle_use_after_close.ty 2>&1 | grep -q 'is used after' Pinned-by: ./tychoc1 --typecheck tests/reject/handle_close_twice.ty 2>&1 | grep -q 'is used after' Pinned-by: sh tests/ffi/run.sh Pinned-by: ./tychoc1 --typecheck tests/reject/handle_close_param.ty 2>&1 | grep -q 'is a handle parameter'
The handle — row of #128. close(h) runs the destructor and nulls the variable, so every later use passed null to C, a second close was silently a no-op, and is_null(h) answered true for "closed" and for "the opener failed" alike. Spec §25 documented use-after-close as permitted. For the one resource kind whose whole point is *exactly once*, that left the remaining misuse undetectable both at compile time and at run time.
Neither compiler has dataflow, and none was added. The rule leans on two properties handles already have: they are affine and they cannot be reassigned. A close(h) statement therefore closes h for the rest of its block, and blocks nested after it. Every later mention there is on a path through the close, so it is rejected: a call that passes h, a second close(h), and is_null(h), which could only be a constant true. A close inside an if, a loop body or an arm reaches only the end of that block, so a use after it is a maybe and still compiles. The rule claims only certainties, and the maybe case is exercised by tests/ffi/main.ty@use_res_branch_close. src/tychoc.c@shut_mark records the closed variable and resolve_block truncates the record at the block's end. The E_IDENT resolve refuses a closed variable, so shadowing is handled by the ordinary scope lookup. compiler/types/tcheck.ty@_shut_mark does the same in _tblock and in _value_type, whose value-arm statements bypass _tblock. A first cut missed that path and diverged on a close inside a value if arm. The message is word-for-word in both compilers.
What stays open is the maybe path. There is_null(h) still cannot distinguish a branch that closed from an opener that failed. Answering that would need a second run-time state or a new builtin, and the surface is frozen. The spec says to keep a bool beside the handle.
A finding made on the way, not fixed here: close(h) on a handle parameter compiles. The callee nulls only its own copy, so the caller's scope-exit free runs the destructor a second time. That is a double free, and it contradicts §25's "the callee does not free it". FIXED 2026-09-24: close(h) on a parameter is now a compile error in both compilers (src/tychoc.c@close_param_check; tychoc1 marks VBind.param where it pushes a parameter). Reproduced first with a destructor that counted its calls: two frees for one open. Function, lambda and generic parameters are all refused; sink/inout handle parameters were already refused at the declaration. Pinned by tests/reject/handle_close_param.ty.
131. tychoc could not build an inline array reassigned from a call inside a loop — FIXED 2026-09-24
Pinned-by: make -s grade-check
Found by tools/tycho-grade/ (#128, layout 4) on its first build: gain = parse4(...) inside the argument loop, with gain a vector[4]f32 read more than once. The reference compiler's in-loop spine recycle (src/tychoc.c@do_recycle) frees a dynamic array's old buffer before the reassignment, and its guard excluded only bounded[N]T from the set of arrays. A vector[N]T and a plain [N]T also store their elements inline, with no .data or .cap, so the emitted recycle was a cc error in the generated file. The guard is now IS_INLINE_ARR, which covers all three. tychoc1 never had it. The pin was run against the unfixed compiler first and goes red there; tests/inline_arr_reassign_loop.ty exercises all three kinds.
132. math.clamp refused a vector, so a grading kernel's clamp left the vector unit — FIXED 2026-09-24
Pinned-by: make -s vector-check Pinned-by: make -s grade-check
#128's layout 6. math.min, math.max and math.clamp are where comparable(T), and a vector is not comparable, so tools/tycho-grade/ did its multiply-add as one vector[4]f32 operation and then clamped four lanes in a scalar loop. The surface is frozen, so the fix is not a new name: the three existing functions accept a vector and are lane-wise. Each lane gives the scalar body's answer, down to the order of clamp's two tests, which decides the result when lo > hi.
It is a compiler rule and not a math.ty change, because no Tycho body can say "select per lane": if a < b on two vectors has no lane-wise meaning, and surface.lock records the where comparable(T) signature, which stays as it is. Both compilers recognise the three templates by name *and* by their file lying under the corelib root (src/tychoc.c@vlane_kind, compiler/emit/emit.ty@_vmath_of), skip the where and the body walk for them, and emit the body themselves. C has no vector ?: (gcc and clang take it only in C++ and OpenCL), so the body is a lane mask from one vector compare and a blend through a same-size cast. clang -O1 turns a vector[4]f32 clamp into two fcmgt and two bit, with no loop.
Two things turned up on the way:
tychoc1'sunder_corelibcompares directory listings when one side is relative. So a user'smath/math.ty"is"corelib/math, and the first cut replaced a user package's ownminbody.types/load.ty@from_corelibis a textual prefix test, and it is exact for what the loader builds from acore:import. The looser function still drives the unused-local exemption. That is left as found.tychoc1refuses-3.0in avector[4]f32literal ("element 3 … is the wrong type"), and./tychocaccepts it. The fixtures here write0.0 - 2.5. That parity gap is not fixed here.
133. Inferred-local placeholders collided at a decimal prefix — FIXED 2026-09-26
Pinned-by: make -s test
The 20,000-seed compiler fuzzer hit seed 8405: after enough []/None placeholders, tychoc1 emitted invalid C (TychoArrInt1 and a stray numeric suffix). The minimal case is ten inferred empty-array locals followed by a push into each. _fnbody replaced _PT1 before _PT10 (and likewise _PE1 before _PE10), so the shorter token consumed the prefix of the longer. It now substitutes in descending allocation order. tests/emit_many_inferred_arrays.ty pins the ten-placeholder case; the unfixed output was checked to fail C syntax compilation, while tychoc and the fixed tychoc1 both build and print 10.
134. Generic constructor resolution retained pointers across type-table growth — FIXED 2026-09-26
Pinned-by: make -s test
A 17-deep Wrap(Wrap(...(41))) made tychoc SIGSEGV while resolving a valid program. The generic constructor held a StructDef * (and the enum path held EnumDef */Variant *) while recursively resolving payloads. A nested generic constructor could grow and relocate the definition table, invalidating those pointers. The resolver now retains stable table indices and re-reads definitions around recursive calls. tests/generic_ctor_realloc.ty crosses the table-growth boundary for both generic structs and generic enums; both compilers build it and it prints 41 and enum.
135. tychoc1 left sized and element-wise integer division unguarded — FIXED 2026-09-26
Pinned-by: make -s test
The audit's D04/D08 repros were confirmed: division by zero in u32 and in an array lane reached raw C /, which can SIGFPE/SIGILL instead of producing a Tycho diagnostic. The self-hosted emitter now routes signed and unsigned sized integer / and % through the runtime guards, including array, vector and broadcast lanes; the type checker checks literal zero before the element-wise early return. tests/abort/{sized_div_zero,array_div_zero,sized_array_div_zero}.ty and tests/reject/array_div_lit_zero.ty pin the cases. Both compilers now trap with tycho: division by zero for runtime zero and reject the literal divisor.
136. Empty literals slipped through fixed-array arity checks — FIXED 2026-09-26
Pinned-by: make -s test
[] was accepted for [N]T and vector[N]T: the checker had a fixed-count check only on the non-empty literal path. tychoc then emitted an uninitialized inline aggregate; tychoc1 built a zero-length value with a fixed type. The fixed-array count check now includes zero elements, and the self-hosted checker refuses the empty literal in both fixed arrays and vectors. The rejecting twins tests/reject/fixed_array_empty_literal.ty and tests/reject/vector_empty_literal.ty cover both compilers.
137. Const-sized arrays lost their capacity in the self-hosted checker and emitter — FIXED 2026-09-26
Pinned-by: make -s test
_fixsize treated "is a const" as "unknown size", disabling literal arity, push/pop and parameter compatibility checks for [N]T; zero and negative const sizes also escaped. It now resolves the folded const value and enforces a positive length. The emitter separately parsed a vector[N]T count as a raw integer token, turning a valid const into vector width zero; it now resolves same-package consts before registering the vector type. tests/const_sized_arrays.ty pins fixed-array and vector length/element behavior, and five reject fixtures pin arity, push, zero/negative sizes and parameter mismatch.
138. If/elif chains were nested and quadratically emitted — FIXED 2026-09-27
Pinned-by: sh tests/elif_chain.sh
Audit D03/D23 reproduced cubic memory in tychoc1: every nested IfS copied and indented the remaining chain, while the emitter repeatedly concatenated its output. The statement AST now stores a flat IfArm list; typechecking, name resolution, analysis and emission walk each arm once. C output is a linear if/else if chain, assembled from pieces in one pass. A synthetic 10,000-arm chain compiles and runs with both compilers; the generated C stays below 1 MB.
139. Native C emission nested every elif arm — FIXED 2026-09-27
Pinned-by: sh tests/elif_chain.sh
Audit D14 found that tychoc emitted else { if (...) { ... } } at one more brace and indentation level per arm. gen_stmt now walks the existing else-chain iteratively and writes else if at a constant depth. The 10,000-arm case builds and runs with gcc, and the 300-arm case also builds with clang, for both compilers.
140. Builtin shadowing and self-named initializers miscompiled — FIXED 2026-09-27
Pinned-by: make -s test
Audit D09/D10 exposed two emitter mismatches: total := total(...) made the C local hide the function in its own initializer, and tychoc1 dispatched a shadowed builtin such as keys to the user function despite the resolver's warning. Both emitters now evaluate self-naming initializers through a temporary; the self-hosted builtin-dispatch list matches the resolver and C bootstrap. The existing tests/shadow_builtin_selection.ty now pins both builtin precedence and the self-named function call.
141. Native source loading assumed seekable files — FIXED 2026-09-27
Pinned-by: sh tests/stream_source.sh
Audit D11 found that ftell returning -1 for a pipe became a SIZE_MAX-byte fread into an empty allocation. tychoc now reads sources into a growing buffer and checks read errors and size overflow. Both compilers accept source from /dev/stdin and a FIFO; they also refuse /proc/self/cmdline, whose procfs st_size is zero despite nonempty NUL-delimited content. The test is separate from program stdin.
142. Literal fit checks were skipped by several coercion paths — FIXED 2026-09-27
Pinned-by: make parse-check; TYCHO_THREADS=8 make -s test
Audit D06's variadic, UFCS, lambda-return, map-default, channel-send, subscript-store, value-match and f32 literal cases are now checked at the expected destination type. Variadic extras inherit the element type; UFCS resolves the receiver's signature before checking arguments (including chained calls); and the lambda's tail expression uses its declared/expected result type. Both compilers also range-check the scalar broadcast against a sized-array element; the native resolver now calls sized_lit rather than merely tagging the expression. Added ten reject fixtures for the coercion paths and f32 array and vector literals.
143. Place stores formed stale addresses before an inout RHS could grow the container — FIXED 2026-09-27
Pinned-by: sh tests/place_store_order.sh
Audit D01 reproduced both an array write into recycled arena memory and a lost map update: xs[i] = grow(&xs, &i) and m["x"] = grow(&m, 7). The emitters now evaluate non-literal index/key legs before the RHS, materialize the RHS, and form the destination address/slot only after the call returns. Compound map updates snapshot the old value and re-find the slot after RHS growth. The new tests/place_store_order.ty checks array reallocation, map rehash, index mutation during the call, and compound map assignment; both compilers also pass its clang build.
144. tychoc1 -g emitted unescaped source paths in #line directives — FIXED 2026-09-27
Pinned-by: sh tests/debug_path.sh
Audit D12's backslash and quote could corrupt the C #line string; a newline could split the directive itself. Both emitters now escape quotes, backslashes, line breaks, tabs, and other control bytes. tests/debug_path.sh builds and runs both compilers with quoted/backslash and newline paths, under the default C compiler and clang when available. The separate GDB-version wording finding D90 remains environment-specific.
145. program_in retained every growing struct-definition prefix — FIXED 2026-09-27
Pinned-by: sh tests/struct_defs_scale.sh
Audit D15 found the self-hosted emitter appending each struct field to head, then prepending other generated declarations to that same string. That made the compiler's accumulator analysis miss the append loop, retaining quadratic intermediate copies (the 900-struct repro peaked above 4 GiB). Struct-definition fragments now collect separately and join once before head is assembled. The regression emits 900 structs with 20 fields each under a 1.5 GiB virtual-memory limit when the host supports it.
146. Deep integer sums and interpolated strings overflowed compiler/C nesting — FIXED 2026-09-27
Pinned-by: sh tests/expression_scale.sh
Audit D16's flat 1,400-term integer sum recursed through the self-hosted checker/emitter and exhausted the compiler stack; D17's 256-term sum and 130-hole f-string exceeded clang's bracket limit. Simple-int + left spines now typecheck and emit iteratively, with the shared 2,000-node diagnostic retained for larger sums. Other deep left-associated binary spines are refused at 1,024 nodes, and recursive expression checking has a depth guard; the parser also pre-scans and refuses over-256 nested parentheses/calls or consecutive unary operators before recursive descent. Both emitters flatten f-string joins into a sequential statement-expression instead of nesting one block per piece. The generated tests pass 1,400 terms on both compilers, refuse 2,101-term sums and subtractions with located diagnostics, reject over-deep calls/unary chains, and compile the 256-term/130-hole clang cases.
Correction 2026-10-16: the flat int-sum emission above was true of the self-hosted compiler only. The reference compiler kept one parenthesis pair per + until 4668aeb3, so its 1,400-term sum reached C 1,402 levels deep: Debian clang 19 refused it at 256 (-fbracket-depth) and Apple clang 21's frontend crashed. The pin passed make ci on macOS for three weeks because make raises RLIMIT_STACK and Apple clang survived under it; run from a plain shell, or on Linux at all, it failed. The pin now fixes its own stack limit (ulimit -s 8192) so it cannot inherit the parent's.
147. Typed tuple literals used synthesized element types in C — FIXED 2026-09-27
Pinned-by: make -s test
Audit D20's (float, u8) = (1, 2) passed typechecking but emitted a different C tuple struct: the self-hosted emitter chose the tuple type from synthesized (int, int) elements, despite checking each element under the destination component type. It now constructs the C tuple with the expected tuple type and emits integer-to-float literal adaptation. The fixture covers declarations, returns, function arguments, struct fields, array elements and Some payloads; both compilers produce the same values.
148. The self-hosted emitter accepted pow and SoA loops in typechecking but had no lowering — FIXED 2026-09-27
Pinned-by: make -s test
Audit D25 exposed two phase-parity gaps: pow(x, y) was registered in the checker but fell through to an unsupported-call internal error, and the checker accepted for p in soa_value while the emitter only handled arrays and strings. The emitter now lowers pow to libm and SoA iteration through the per-element gather helper, preserving a stable length snapshot when the body mutates its source. The positive fixture runs on both compilers.
149. F-string holes ignored trailing syntax and rejected surrounding whitespace — FIXED 2026-09-27
Pinned-by: make -s test
Audit D24 found that both compilers accepted only the first expression in a hole ({price:.2f} and {n, 99} silently printed the value), while the self-hosted re-lexer treated { n } as unexpected indentation. Hole text is now trimmed before lexing, the expression parser must consume the entire fragment, and both brace scanners skip quoted character literals as well as strings. The positive fixture covers whitespace and a } character literal; the two reject fixtures pin format-spec and trailing-expression refusal.
150. Sized float conversions differed from the total wrapping contract — FIXED 2026-09-27
Pinned-by: make -s test
The self-hosted emitter sent to_u8..to_i64 from float/f32 through tycho_f2i, which traps outside the signed 64-bit range (and on NaN/inf). The native emitter already used tycho_f2u64, whose documented conversion truncates and wraps finite inputs modulo 2^64 and maps non-finite inputs to 0. The self-hosted lowering now uses the same helper. The fixture checks negative fractional wrap, an in-range signed conversion, to_u64(1e19), and NaN under both compilers.
151. Equal float leaves in composite map keys hashed differently — FIXED 2026-09-27
Pinned-by: make -s test
Float equality treats 0.0 and -0.0 as equal, but hashing their IEEE-754 bit patterns differed. A map keyed by a struct (or nested struct) could therefore store two equal keys and fail to find an overwrite. Both emitters now canonicalize zero before hashing; runtime array-of-float key hashing uses the same helper. The nested-key fixture inserts +0.0 then -0.0 and verifies one entry with the updated value under both compilers.
152. Self-hosted composite map-key validation admitted un-emittable leaves — FIXED 2026-09-27
Pinned-by: make -s test
The self-hosted key predicate treated every sized integer and f32 as a valid leaf in a composite map key, while the native compiler and supported hash emitter accept only the shared leaf set. A struct key with either field passed typechecking and then referenced an undefined generated hash function. The predicate now matches the native set and refuses these keys before emission.
153. Large unsigned shift counts were misread as negative — FIXED 2026-09-27
Pinned-by: make -s test
Shift helpers took a signed long long count. Passing a u64 count at or above 2^63 converted it to a negative value, so the runtime aborted instead of applying the specified count >= width -> 0 rule. Both emitters now route counts through a helper that distinguishes the right operand's signedness before the width check; negative signed counts still abort. The shift-edge fixture covers both left and right shifts with a u64 count of 2^63, while the abort fixture keeps the negative signed-count diagnostic intact.
154. Synthesized locals could shadow user bindings — FIXED 2026-09-27
Pinned-by: make -s test
Both front-ends emitted internal locals in the same identifier namespace as source bindings. A user _fc0, _cx0, _ms36_0, or _mx36_0_0 could therefore be shadowed by a for-in or assignment temporary, silently changing results. Synthetic binding names now use Ztycho_; the lexers reject that prefix in source, and resolution permits it only for the internally synthesized AST bindings. The multi-assignment fixture pins the exact _mx36_0_0/_ms36_0 collision shape, the for-in fixture covers _fc0/_fi0, and the reject fixture defines the reserved namespace boundary.
155. Resolver recursion depth leaked across recovered diagnostics — FIXED 2026-09-27
Pinned-by: sh tests/depth_recovery.sh
The native resolver uses longjmp to recover from a bad statement and continue collecting diagnostics. When an error unwound nested resolve_exp calls, their normal depth decrements were skipped; after 2000 ordinary errors every later statement was misreported as too deeply nested. Recovery boundaries now reset the counter. The generated check sends 2105 independent unresolved-name errors through both compilers and verifies there are no fabricated depth errors.
156. Enum variant spellings leaked into generated C identifiers — FIXED 2026-09-27
Pinned-by: make -s test
Payload variants were emitted as C union members and E_<enum>_<variant> payload typedefs. C keywords (default, case, while), standard macros (EOF, NULL), and a user enum named Shape_Circle could therefore break the C build. Both emitters now use prefixed C-only union fields (V_<name>) and payload types (TychoPayload_<enum>_<variant>), while diagnostics and str() keep the source spelling. The fixture exercises all three collision classes.
157. Deep non-addition binary spines could exhaust the self-hosted typechecker — FIXED 2026-09-27
Pinned-by: sh tests/expression_scale.sh
The flat-integer-add path was bounded and iterative, but other left-associated binary operators still recursed through the entire AST; a 2101-term subtraction stack-overflowed tychoc1 instead of returning a diagnostic. The self-hosted typechecker now guards recursive expression walks, and the native resolver checks non-+ left spines iteratively before descending. Both reject the deep subtraction with a located depth diagnostic, while 1400-term + remains accepted and 2101-term + retains its specific max-2000 diagnostic.
158. Double underscores in source type names were mistaken for compiler mangling — FIXED 2026-09-27
Pinned-by: make -s test
The self-hosted variant ownership check split every type name at its first __, so a main-package enum named Shape__Kind was misclassified as belonging to a package. Generic instance names also used Box__int without checking for a source struct of that exact name, producing duplicate C definitions. Ownership now uses the recorded package prefix, and colliding generated instance names receive a deterministic _2 suffix (matching the native compiler). The fixture covers enum matching and the generic/source-name collision.
159. Large constant array literals gave gcc one store per element to optimize — FIXED 2026-09-27
Pinned-by: sh tests/array_literal_scale.sh
A 15,625-element constant table lowered to 15,625 assignments inside a GNU statement expression; gcc -O3 spent most of its time optimizing that giant basic block. Both emitters now recognize large (64+) scalar-literal arrays, write a block-scope static const initializer, allocate the destination in its arena, and memcpy the constant bytes once. Nonconstant and heap-element arrays keep the element-wise path and its ownership copies. The generated test compiles and runs the 15,625-element case under -O3 with both compilers.
160. F-string C joins and lambda scope snapshots grew quadratically — FIXED 2026-09-27
Pinned-by: sh tests/emitter_memory_scale.sh
_fchain repeatedly concatenated the whole emitted prefix for every piece, and _lambda copied each enclosing local table before compiling each closure. The long f-string now joins an array of C fragments once; lambda bindings are appended and removed with the existing mark/restore discipline instead of copying the outer tables. A bounded generated check emits 4,000 f-string holes and 8,000 lambdas each capturing a local below a 1.2-GiB address-space limit.
161. Packed fields passed to inout were dereferenced at natural alignment — FIXED 2026-09-27
Pinned-by: make -s test Pinned-by: sh tests/packed_inout_ffi.sh
Passing &packed.field as T * lets the callee dereference an address that need not satisfy alignof(T). Both emitters now compute the field's byte address from its packed aggregate plus offsetof, copy into an aligned local with memcpy, call through that local, and copy the result back. The same lowering covers array elements, user-defined subscript projections, and extern calls. The fixture runs all three place forms under AddressSanitizer/UBSan; tests/packed_inout_ffi.sh also covers an extern call under UBSan and -Werror=address-of-packed-member.
162. The debug gate accepted only one GDB spelling for a missing line — FIXED 2026-09-27
Pinned-by: make -s debug-check
GDB 15.1 reports No line 999 in file, while GDB 17 reports No compiled code for line 999; the gate had required only the latter. tools/tycho-debug/run.sh now accepts either version-specific message. make debug-check passes all seven legs on Fedora's GDB 17.2, including the previously load-sensitive Ctrl-C leg. The Ubuntu 24.04/GDB 15.1 binary is unavailable on this host, but its reported message is explicitly covered by the matcher.
163. Native generic mismatch diagnostics exposed package-mangled names — FIXED 2026-09-27
Pinned-by: sh tests/package_diagnostic_name.sh
When a package-qualified generic call failed parameter-pattern matching, the native compiler printed its internal symbol (math__clamp) while the self-hosted compiler printed the source name (clamp). The native diagnostic now uses nominal_name() like its sibling paths. A focused script checks that both compilers reject the mismatched call without exposing an internal mangled symbol (the native diagnostic retains the package qualifier).
164. C-string shims silently discarded bytes after an embedded NUL — FIXED 2026-09-27
Pinned-by: make -s corelib
The remaining crypto-hex and SQLite path/SQL inputs now reject interior NULs before crossing a C-string ABI. Conversely, output APIs that are byte streams (io.read_line, os.run, and os.exec_out) now cross as length-carrying bytes and convert to Tycho strings only after the explicit length is known. The corelib goldens cover binary-safe line/fold reads and child output containing A\0B, alongside the crypto and SQLite rejection cases.
165. Compression truncated zlib lengths and under-enforced its output ceiling — FIXED 2026-09-27
Pinned-by: make -s corelib Pinned-by: sh tests/compress_limits.sh
The gzip and raw inflate paths now clamp growth to the actual output ceiling, probe one byte beyond it so an exact-limit stream can finish its trailer, and reject trailing non-member data while supporting concatenated gzip members. All four zlib entry points refuse lengths that cannot fit uInt; compressor output bounds are checked before narrowing avail_out. The reduced-ceiling C gate proves the inclusive 65,536-byte boundary, the first byte over it, and oversized lengths without allocating multi-gigabyte buffers.
166. POSIX timezone offsets accumulated unbounded decimal fields — FIXED 2026-09-27
Pinned-by: make -s corelib
offset_at now parses offset and transition components with digit-by-digit bounds, limits zone offsets to 24 hours and transition times to POSIX's extended 167-hour range, and validates month/week/day and Julian rule ranges before casting. The datetime golden includes oversized hours, minutes, seconds, and a 20-digit offset that previously returned a nonsensical positive value.
167. Handle destructors could return aggregate values that require hidden out-parameters — FIXED 2026-09-27
Pinned-by: make -s test
The FFI declaration check now refuses bytes, [int], or [float] destructor returns in both compilers: scope-exit and explicit close calls pass only the handle, whereas those C ABIs require two additional output parameters. New reject fixtures pin both the byte and scalar-array cases.
168. A checkpoint could truncate the only committed store before its replacement was durable — FIXED 2026-09-27
Pinned-by: make -s db-check
core:io.rename now provides same-filesystem atomic replacement. store.flush writes a sibling temporary file, syncs it, and renames it over the old store; wal.checkpoint then syncs the new file and parent directory before rewinding the WAL. Replacement preserves the existing regular file's mode bits on POSIX and its ACL on Windows, rather than inheriting the temp file's permissions. The database gate checks 0640 and 0600 survive replacement, then forces a partial temp write with a 512-byte file-size limit, checks the old store is byte-identical, and reopens it with all rows.
169. A mutation remained visible after its WAL append was refused — FIXED 2026-09-27
Pinned-by: make -s db-check
exec._create and exec._insert now validate without mutation, append the WAL record, and only then apply it. The NotWritten leg follows a refused CREATE with SELECT and INSERT and verifies neither can observe a table that was never logged.
170. Separate tycho-db processes could overwrite one another's commits — FIXED 2026-09-27
Pinned-by: make -s db-check
core:io.lock_exclusive uses a nonblocking process lock (flock on POSIX, LockFileEx on Windows); wal.open_db acquires <store>.lock before reading store or log state. The OS releases it on process exit, including a crash. The database gate attempts a local CLI write while the server owns the same store and requires a database is locked refusal before any output.
171. Store decoding trusted row shape and integer encodings — FIXED 2026-09-27
Pinned-by: make -s db-check
The decoder now checks row arity and value/column types before rebuilding an index, refuses trailing bytes, unknown column-tag bits, truncated values, positive magnitudes outside int64, and noncanonical negative zero. Signed magnitude encoding handles INT_MIN without negating it. Six corrupted-store fixtures must fail as Corrupt with empty stdout rather than panic or accept malformed data.
172. A corrupt store plus an empty WAL could be rebuilt and overwritten — FIXED 2026-09-27
Pinned-by: make -s db-check
wal.open_db no longer treats a zero-length log as evidence that a corrupt store can be reconstructed from the beginning. The gate checks that opening such a pair fails without answering or changing the damaged store.
173. A silent tycho-db client could monopolize the serial server — FIXED 2026-09-27
Pinned-by: make -s db-check
Server reads now have a one-second absolute line deadline (including slow-drip input), and a session is capped at 60 seconds. net.Timeout is reported as an idle timeout and the connection is closed before the next is accepted. The gate keeps an idle TCP connection open while a second real client must complete.
174. An indexed cross-type comparison returned no rows instead of an error — FIXED 2026-09-27
Pinned-by: make -s db-check
Before opening an IndexEq path, exec checks the literal type against the indexed column. SELECT and EXPLAIN both reject INT KEY = text with the same Uncomparable diagnostic as the scan path; direct regression legs prevent an empty index result from hiding the type error.
175. Sorted-array KEY index maintenance copied every shifted entry — FIXED 2026-09-27
Pinned-by: make -s db-check
The sorted vector was replaced with a hash index from a type-tagged string key to insertion-ordered row positions. Inserts and index rebuilds are amortized linear overall instead of repeatedly shifting/deep-copying prior entries. A 20,000-row reverse-order insert plus flush/reopen/probe runs under a 192-MiB address-space limit in tests/fixtures/tycho_db_index_scale.src.
176. recv(ch) returned a payload allocated in the callee's freed scope — FIXED 2026-09-27
Pinned-by: make -s test Pinned-by: sh tests/recv_loop_scale.sh
The self-hosted emitter's receive lowering now passes cs.arena both to the channel-copy helper and to the Some allocation. A receive used as a direct return, after a local bind, and through or_else is stressed with arena churn in tests/recv_escape.ty; both compilers preserve the 16-KiB payload.
177. bytes and scoped string accumulators bypassed self-hosted in-place append — FIXED 2026-09-27
Pinned-by: make -s test Pinned-by: sh tests/accumulator_scope.sh
Accumulator discovery/emission now covers bytes plus match/select and related nested scopes. The 50,000-iteration fixture runs under both compilers and checks the exact output; the dedicated gate applies a 1.2-GiB process limit so a return to repeated whole-value concatenation is caught as a scale regression.
178. Several untrusted sizes reached allocation or narrowing operations unchecked — FIXED 2026-09-27
Pinned-by: make -s test Pinned-by: sh tests/audit_runtime_guards.sh Pinned-by: sh tests/http_header_guard.sh
Array reserve checks negative and excessive capacities before its no-op fast path, and composite-array multiplication is checked before allocation. Map reserve rejects negative/over-index-range capacities before doubling or rounding its entry/index sizes; focused fast- and composite-map cases exercise both compilers. Channel-cell sizing checks multiplication and each power-of-two doubling before allocation. Regex group indices are bounded before capture-vector allocation, and subjects are capped at 4,096 bytes; the runtime gate executes a failing a+b search at that inclusive limit under a 10-second timeout, then checks that over-limit subjects are refused. The HTTP client refuses control characters and values too long for its serialized Content-Type header; the net shim refuses descriptors that do not fit its OS int descriptor type. Runtime and header gates exercise these guards under both compilers.
179. TLS timeout wrappers existed only as package-private helpers — FIXED 2026-09-27
Pinned-by: make -s corelib
tls.connect_timeout and tls.set_timeout_ms are public API and recorded in surface.lock. Both reject millisecond values outside signed 32-bit range; the Windows connect path now uses nonblocking connect plus a bounded wait. Targeted TLS tests pass on this POSIX host; the Windows path remains unvalidated on a Windows host because neither one nor Wine is available here.
180. HTTP parsing conflated absent fields, malformed framing and malformed request grammar — FIXED 2026-09-27
Pinned-by: make -s server-check
httpd.has_header distinguishes an absent field from a present-but-empty one; framing rejects any Transfer-Encoding and empty Content-Length. The parser now rejects invalid field names/values, obsolete folding, extra request-line tokens and unsupported versions. Range positions ignore leading zeroes when enforcing the digit limit. Empty/duplicate headers and malformed request lines are pinned by the server gate. (The separate 413-vs-431 status distinction remains open.)
181. The FFI integer table and SQLite example contradicted the compiler — FIXED 2026-09-27
Pinned-by: make -s docs-fences Pinned-by: make -s corelib-doc-check
The FFI reference now distinguishes C int (i32) from Tycho int (signed 64-bit), and describes to_i32 as a narrowing conversion. The SQLite example now uses the actual sized FFI types and builds with both compilers; stale NUL, append, path and corelib boundary descriptions were corrected as well.
182. TOML table placement discarded implicit parents and array-of-table children — FIXED 2026-09-27
Pinned-by: make -s corelib
The TOML placer now merges a later explicit parent table with the implicit table created by an earlier child header, and nested headers descend into the latest array-of-tables element. The corelib fixture asserts that parent and child keys both survive and that subtables attach to the right array element.
183. TOML parsing had unbounded recursion and quadratic table reconstruction — FIXED 2026-09-27
Pinned-by: make -s corelib
The parser now refuses documents over 64 KiB, more than 512 assignments/headers, or nesting beyond 128 levels (including dotted key/header paths). These bounds cap the worst-case cost of the current insertion-ordered table representation; fixtures cover deep values, deep headers, excessive entry counts and oversized input. Valid smaller TOML remains parsed as before.
184. ZIP creation truncated names and counts into classic-format fields — FIXED 2026-09-27
Pinned-by: make -s corelib Pinned-by: make -s snap-check
zip.create_checked rejects entry counts, name lengths, data sizes and offsets that do not fit the classic ZIP fields; the old create entry point is now a fail-fast wrapper instead of silently forging an archive. The corelib fixture checks both the 65,536-byte name and 65,536-entry boundaries, and tycho-snap uses the checked API.
185. TOML dotted keys split inside quotes and malformed strings became valid values — FIXED 2026-09-27
Pinned-by: make -s corelib
A quote-aware dotted-key scanner is shared by assignments and table headers; basic quoted keys decode escapes, duplicate headers compare canonical keys, and bare keys are syntax-checked. Literal strings with trailing text and \\u surrogates now return Err. Corelib fixtures cover quoted dots/escapes, quoted headers, canonical duplicates, trailing literal text and surrogate escapes.
186. JSON object parsing rescanned all prior keys for every member — FIXED 2026-09-27
Pinned-by: make -s corelib
_parse_object now keeps a per-object hash map from key to its original slot. Duplicate members still replace the value at the first key's position, while unique-member lookup no longer scans the growing key vector. The corelib test parses and probes a 20,000-member object.
187. Markdown rescanned the same found ] for every unmatched [ — FIXED 2026-09-27
Pinned-by: make -s corelib
Inline rendering now caches the found ] and ) positions and reuses them while subsequent link/image openers precede those terminators. A closing delimiter that fails to form a link no longer forces every following [ to rescan to the same byte. Corelib output checks 20,000 [ plus ] and 20,000 ![ plus ] shapes remain byte-for-byte literal.
188. ZIP extraction rescanned the central directory and unbounded file suffix — FIXED 2026-09-27
Pinned-by: make -s corelib Pinned-by: make -s snap-check
Each zip.Meta now carries its local-header offset, and extract_entry(bytes, meta) consumes it directly. Iterating one list() result avoids a full directory walk per entry. EOCD search is restricted to the ZIP-legal 65,557-byte suffix and requires the comment to end at EOF; the malformed trailing-junk test is rejected.
189. Option reference claimed a bare None comparison was valid — FIXED 2026-09-27
Pinned-by: make -s parse-check Pinned-by: make -s docs-fences
The reference now agrees with the normative rule: a bare None has no expected type in ==/!=; use a typed value or is None. The self-hosted typechecker rejects ungrounded Option/Result operands with a located error instead of accepting them for a later location-less emitter failure. Three reject fixtures cover Some(1) == None, opt == None and bare Ok/Err comparison.
190. Self-hosted builtin-collision advice mislabeled subpackages as main — FIXED 2026-09-27
Pinned-by: make -s test
Builtin-shadow warnings now use the declaring package prefix captured during global symbol registration, not the resolver's later current-file state. The warning distinguishes package-local calls from external qualified calls. A subpackage len fixture checks the advice.
191. Self-hosted emitter could not construct generic enum variants through a package qualifier — FIXED 2026-09-27
Pinned-by: make -s test
Qualified calls now search generic enum templates only in the declaring package, then infer type parameters from payloads or apply explicit type arguments before emitting the specialized variant. tests/pkg/generic_enum_variant_cross checks both box.Has(41) and box.Nothing$(int) under both compilers.
192. Self-hosted loops retained overwritten strings and variadic packs in _scope — FIXED 2026-09-27
Pinned-by: make -s test Pinned-by: sh tests/loop_temp_recycle.sh
[string] element stores now evaluate into the iteration scratch and call the runtime's copy-and-recycle setter. Variadic packs and their transient arguments also use the current loop scratch (_curown) instead of the function arena. A two-million-iteration overwrite plus variadic-call test runs under a 96-MiB virtual-memory limit for both compilers.
193. Replacing a heap value in a long-lived map abandoned its owned graph — FIXED 2026-09-27
Pinned-by: make -s test Pinned-by: sh tests/map_overwrite_scale.sh
Both emitters now copy map replacements into the owner before recursively recycling the evicted value. Generated typed recyclers cover nested arrays, composite maps, structs, enums, tuples, options/results, struct-of-arrays and captured closure environments; composite-key and value allocations are reclaimed on delete as well. String leaves use the runtime's owner-checked recycler. The 96-MiB regression exercises 200,000 string and nested-array replacements plus 100,000 struct, enum and captured-closure replacements, then deletes an enum value, under both compilers.
194. HTTP request-budget overflow mapped a declared oversized body to 431 — FIXED 2026-09-27
Pinned-by: make -s corelib Pinned-by: make -s server-check
ReqErr.BodyTooLarge is now distinct from an unterminated oversized header (TooLarge). The server and tycho-kvsrv map framed body overflow to 413, while header overflow remains 431; reason_phrase(413) and the error-page explanation are present. Corelib's reduced-cap socket test and the server's wire test pin the split.
195. resolve_under treated a dangling symlink as an ordinary missing leaf — FIXED 2026-09-27
Pinned-by: make -s server-check
When realpath fails, resolve_under now checks whether the current path is itself a symlink (lstat on POSIX, reparse metadata on Windows) before walking to its parent. A dangling link is refused; an ordinary absent file under a resolved in-root ancestor still returns its lexical path for a 404. The server gate covers both outcomes.
196. Package suggestions exposed names that the next compile rejects as private — FIXED 2026-09-27
Pinned-by: make -s test
Both package-symbol suggestion paths now omit _-prefixed functions and externs from edit-distance and prefix-related candidates. A package reject fixture with only lib._helper verifies that lib.helper(...) gets no unusable suggestion.
197. Compiler CLIs accepted empty output names, swallowed linker paths and indexed invalid programs — FIXED 2026-09-27
Pinned-by: make -s test Pinned-by: sh tests/cli_flags.sh
--symbols now refuses both resolver and typechecker errors, and prints no partial index for ill-typed input. Both compilers explicitly reject -o "" before either a binary build or --emit-c, without leaving a stray .c file. Separated -L/-I require a non-option directory argument instead of consuming the next flag. The CLI regression checks these refusals under both compilers and both output modes.
198. Native procedure-signature lookup scaled quadratically — FIXED 2026-09-29
Pinned-by: sh tests/proc_signature_scale.sh Pinned-by: sh tests/name_registry_scale.sh
sig_find and sig_find_quiet use an append-aware open-addressed index that preserves first-match behavior. The companion D31 fix in #201 indexes constant, variant and generic registries and caches source line starts for warning snippets. The 40,000-procedure chain and focused remaining-registry stress test both pass.
199. Same-arena copies from replaceable string slots aliased recycled buffers — FIXED 2026-09-27
Pinned-by: make -s test Pinned-by: sh tests/run.sh --one tests/elem_recycle.ty
The self-hosted emitter treated a string read from an array/field slot as safe to share merely because source and destination used the same arena. Replacing the slot can recycle that buffer, corrupting the copied value. _shares now forces a copy for fields, tuple fields and indexed places; elem_recycle covers overwrite, self-assignment and a surviving snapshot.
200. Nested generic calls and constructor payloads repeated subtree work — FIXED 2026-09-29
Pinned-by: sh tests/emitter_nested_calls.sh
The self-hosted typechecker now reuses a UFCS receiver type during generic inference, and the emitter's argument-type path infers call types without re-emitting their arguments. The native resolver likewise reuses concrete payload types after generic struct/enum inference, while still resolving partial None/Ok/Err values against their expected type. The regression compiles 64-deep generic method chains and 32-deep nested generic struct/enum constructors under both compilers, with a 1.2-GiB memory cap and a 20-second compile limit.
201. Native constant, variant, generic and warning-location scans scaled with all prior input — FIXED 2026-09-29
Pinned-by: sh tests/name_registry_scale.sh Pinned-by: sh tests/proc_signature_scale.sh
The native compiler now uses first-match-preserving open-addressed indexes for constants, variants and generic procedures, alongside the procedure-signature index from #198. Both compiler pipelines pre-index source lines once instead of rescanning the source for every warning excerpt. The self-hosted declaration registration uses stable O(n log n) source-order sorting instead of selection sort. Stress coverage registers 12,000 constants and generics, 1,500 variants, and emits 6,000 warnings under both compilers; this lane and the 40,000-procedure chain pass.
202. Native codegen refused deeply nested loops that the self-hosted compiler accepted — FIXED 2026-09-30
Pinned-by: sh tests/nested_blocks_scale.sh
The D03 verifier found that native codegen's loop and arena-unwind stacks were hard-capped at 64, while tychoc1 accepted deeper nesting. Those stacks now hold 256 entries, matching the parser's nesting ceiling. A 125-level nested-loop program compiles and runs under both compilers with a 1.2-GiB cap. Extremely deep nesting remains intentionally bounded; the 250-level self-hosted compile's measured memory cost is not claimed to be constant.
203. Package-local builtin names disagreed between compilers and produced false collision advice — FIXED 2026-09-30
Pinned-by: sh tests/package_builtin_shadow.sh
A bare call inside a package now resolves that package's own declaration before falling back to a builtin, matching native resolution. Package-collision advice states that local calls select the package function and external calls should be qualified. The regression defines lib.pow, checks the warning and verifies the result is 3.0 under both compilers.
204. A missing composite map read segfaulted instead of returning the zero — FIXED 2026-10-18
Pinned-by: sh tests/run.sh --one tests/map_missing_composite.ty
Found by the 2026-10-18 graph probe: m[99].name on [int: Person] with no key 99 exited 139 with no output, on both compilers, contradicting docs/reference/maps.md, which promises a deep copy of the value type's zero. Scalar misses were fine (they desugar to map_get with a literal zero); only composites crashed. The miss default is emitted as a raw C (V){0}, but C-zero is not Tycho-zero: its string members are NULL, and tycho_str_copy dereferences the length header unconditionally. The UBSan leg reports a non-zero offset applied to a null pointer. The place path shared the defect: tycho_mapc%d_slotptr inserts the same (V){0}, so m[5].age += 1 stored a NULL name that later reads, comparisons and display could each trip on.
The fix is one guard in the shared runtime (runtime/tycho_rt.c): a NULL input to tycho_str_copy returns the immortal empty, the same fail-open shape tycho_str_recycle already accepts. That makes C-zero composites behave as the language zero at every touchpoint -- copy, compare, display, delete -- with no codegen change in either compiler. The regression reads missing scalars and composites (field, whole-value, equality, display), exercises the slotptr-inserted zero, and checks a nested-array miss, under both compilers.
205. Self-hosted inout through an array-typed subscript grew into a freed scope, and subscript writeback neither copied nor recycled — FIXED 2026-10-18
Pinned-by: sh tests/run.sh --one tests/subscript.ty
Found by the 2026-10-18 update probe: add(&g.friends_of(a), b) under tychoc1 printed garbage ([1, 91] for [0, 2]) and the full program died with tycho: out of memory; the reference compiler was correct throughout. Two defects, one shape. First, the call-site arena comes from _ownerof(&args[i]), but a subscript call wraps the root (Addr(CallVal) or Addr(Call)), whose root _rootname could not see through, so the call got "&_scope" instead of the owner's _ina_ arena and a push through the projection grew a buffer that was freed on return. Second, whole-value stores through a subscript place (g.person(id) = f(...)) emitted a raw C assignment: no deep copy into the owner's arena (dangling when the RHS died with the caller's scope -- the rename printed a previous error string) and no recycle of the displaced value (leak).
The fix mirrors what the reference does by inlining subscripts at resolve: _rootname sees through CallVal to the receiver, the call site unwraps Addr(Call) the way the lvalue path builds ua2 (receiver q, else first argument), _subinline binds the parameter to the argument's owner instead of "&_scope", and heap subscript stores copy into the owner and recycle the old value, exactly like the _set family the direct index path uses (the reference's own root walks for push, pop, reserve and index-assign live at src/tychoc.c:7929, src/tychoc.c:7964, src/tychoc.c:7987 and src/tychoc.c:9798). tests/subscript.ty gains the array-projection growth and heap-member writeback legs; both compilers print them identically, ASan/UBSan clean. Census goldens move only by the added fixture lines; no counts are lost.
206. Self-hosted push/pop/reserve/index-assign through a subscript accepted what the reference refuses — FIXED 2026-10-18
Pinned-by: test -f tests/reject/push_subscript_place.ty Pinned-by: test -f tests/reject/pop_subscript_place.ty Pinned-by: test -f tests/reject/reserve_subscript_place.ty Pinned-by: test -f tests/reject/index_subscript_place.ty
Found while verifying the probe-update writeups: push(g.fr(0), 1) under tychoc1 sailed through the type checker and died in the C compiler (lvalue required), because _is_place accepts any declared subscript call while the reference's root walk stops at calls. In the other direction, g.fr(0)[0] = 9 compiled and ran correctly under tychoc1 while the reference refuses it -- a quiet divergence either way.
The port mirrors the reference's root walks (src/tychoc.c:7929 for push, src/tychoc.c:7964 for pop, src/tychoc.c:7987 for reserve, src/tychoc.c:9798 for index-assign) exactly: a _root_ident walk descends Field/Index only to a bare Name (deliberately narrower than the borrow rule's _root_name, which also descends TupIdx and Addr), and the four call sites refuse anything else with the reference's own messages. The writeback path in FRICTION 205 already routes subscript stores through the owner arena with copy and recycle, so the newly refused programs are exactly the ones no golden covered. Four reject fixtures pin the four rules; the classifier table, the split counts (TYPE 189, SEMANTIC 320), the corpus pins and the census goldens were re-recorded with dated history.