promote dev → stage: iteration-1 (the compiler stops adjudicating) + accumulated dev #165
Reference in New Issue
Block a user
Delete Branch "dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Promotes
devtostage. 176 commits — 48 fromiteration-1(PR #164) plus accumulated dev work that predates it.iteration-1 verification, re-run on the merged dev:
Six live defects fixed, all pre-existing, all previously silent — including two
memory-safety issues:
let s: String = 42dereferencing address 42, andsha256_hex(50000)segfaulting through an unguardedstrlen. Full record indocs/v1/experiments/.Not verified by me: the 128 commits on dev that predate this session.
runtime/el_seed.c does not compile standalone via the exact command tools/install.sh uses (`cc -std=c11 -O2 -I runtime -c runtime/el_seed.c`): 51 of its __-prefixed wrapper functions (http serving, JSON access, key-val state, URL/HTML escaping, and the whole engram_* node/edge/layer/search surface) call unprefixed counterparts that are implemented in el_runtime.c, not in el_seed.c itself, and el_seed.c never declared them -- a toolchain that treats an implicit function declaration as a hard error under C11 fails the compile outright. install.sh already compiles el_seed.c and el_runtime.c as separate objects and archives both into libel.a, so the symbols are always present at link time; el_seed.c alone was just missing the prototypes. A plain `#include "el_runtime.h"` was tried first and rejected: it redefines el_to_float/el_from_float, which el_seed.h already provides -- a real compile error, not a style preference. Added narrow prototypes instead, copied verbatim from el_runtime.h, for exactly the 51 symbols el_seed.c's wrappers reference and nothing else. Verified clean: - `cc -std=c11 -O2 -I runtime -c runtime/el_seed.c` (install.sh's exact per-file compile) -- 0 errors, 0 warnings, even with -ferror-limit=0. - full `tools/install.sh` run -- compiles both objects and archives them into libel.a successfully. Separately (not fixed here, out of scope): AGENTS.md's documented compiler self-rebuild command links elc-new.c against el_seed.c, but elc-new.c's own generated `#include "el_runtime.h"` line and 3 undeclared symbols (el_mem_check, stdout_to_file, stdout_restore -- present in neither el_runtime.c nor el_seed.c) mean that command fails regardless of which runtime file it's linked against; and install.sh's libel.a only archives el_seed.o + el_runtime.o, so any program that calls into the engram_* surface fails to link against it (el_runtime.c's engram_* wrappers need engram_store.c/engram_geometry.c/engram_reason.c/engram_cognition.c/ engram_vindex.c, none of which install.sh compiles in). Both are real, pre-existing, and independent of this fix -- worth their own look.Three real bugs, all found by actually running the thing rather than reading it. 1. query_param never URL-decoded. A GET of /api/search?q=neural%20network searched for the literal string "neural%20network" and returned []. Every multi-word search against the live engram has been silently returning empty results — not an error, an empty result, which is why it went unnoticed. Affects every GET route that reads query params, not just search. 2. query_param matched key names unanchored. str_index_of(qs, "q=") matches inside "faq=", so "?faq=X&q=Y" returned X for key "q". Verified live before the fix. Now searches for "&key=" against "&"+querystring so a match can only land on a real parameter boundary. 3. el_request_start/el_request_end were defined in BOTH el_seed.c and el_runtime.c, so linking the two objects together — which is exactly what the product build does — failed with duplicate symbols. el_seed.c's own comment already says these moved there ("formerly defined in el_runtime.c. Now self-contained in el_seed.c"); the el_runtime.c copies were left behind during that move. Removed them, kept declarations since http_worker calls them. Also added the three missing prototypes (engram_op_assert_json, engram_node_full_in, engram_connect_in) that el_seed.c wraps but never declared, which made it fail to compile standalone under C99+. Verified: engram builds and links clean from canonical source; before/after comparison on a copy of the real store shows "neural network" returning a real match where the live build returns [], and "?faq=WRONG&q=MetaColloc" now resolving to MetaColloc. Live engram on :8742 was never touched.The committed elc binary could not be refreshed from its own source. Rebuilding failed with three implicit-declaration errors: el_mem_check, stdout_to_file, stdout_restore. The compiler's own source calls all three (compiler.el:472,479,574 and codegen.el:4248) and two are registered in codegen.el's builtin_arity table — but none were defined in this runtime. They were found intact in ui/examples/native-hello-ios/NativeHello/el_runtime.c, a divergent private copy of this runtime that still carried them. Ported verbatim. Consequence of them being missing: the canonical elc binary was frozen. Source gained @route dispatch codegen (emit_route_dispatch, codegen.el:3948) and the @manager boundary-beat seam, but no rebuilt binary could carry them, so neuron's soul — whose routes.el now calls the compiler-synthesized el_route_dispatch — could not be built at all. Verified after the fix: - elc rebuilds from current source, clean. - Self-hosting fixpoint byte-identical (stage3 == stage2). - The rebuilt elc emits el_route_dispatch (2 occurrences in the soul amalgam, previously 0) and injects engram_boundary_beat at @manager boundaries, i.e. the decorator seam is live rather than inert. el_mem_check is itself the compiler's memory guard (ELC_MAX_MEM_MB, default 512MB, self-terminates before the OS OOM-killer fires) — so the runtime was missing the very guard that would have surfaced the compiler's memory blowup as a clean error instead of a 27GB host-killer.neuron's soul calls engram_recall_json (neuron-api.el:618, memory.el:80) and cgi_principal (studio.el:72). Both existed in the runtime neuron vendored (v1.0.0-20260501) and were absent here, so the soul could not link against current el at all. The dangerous part is what the obvious "fix" would have done. These look like redundant wrappers over one impl: engram_search_json(q, limit) -> eg_search_json_impl(q, limit, 0) LEXICAL engram_recall_json(q, limit) -> eg_search_json_impl(q, limit, 1) SEMANTIC They are not interchangeable, and the split is documented at neuron-api.el:613: search stays LEXICAL because ~40 internal call sites pass a KEY and seven of them DELETE every record returned. Point those at a semantic matcher and they delete fuzzy matches. Conversely, pointing recall at search silently downgrades the mind's entire retrieval surface from semantic to lexical — no error, just permanently worse recall. Implemented over engram_activate(), which in this runtime already IS the semantic path the old with_legs=1 branch built by hand (embeds the query via eg_embed_fetch, scores by cosine, then spreads activation one hop). Output shape matches engram_search_json — a flat array via engram_emit_node_json — because callers parse search's shape, not activate's envelope. Verified: neuron's soul now compiles and links against current el, boots, and serves /health with layers initialized. NOTE for follow-up: current el also ships engram_retrieve_geometric_json, a structure-first retrieval that appears to be the intended successor to recall. Repointing the two recall call sites at it may well be the right end state and would remove the two-wrapper shape entirely — but that is a behavioral change that must be measured against neuron/tools/retrieval-eval/'s gold set, not assumed. This commit preserves existing behavior exactly; it does not decide that question./api/graph/edges answered a read query by calling engram_save() to serialize the ENTIRE graph to disk (128 MB) and then fs_read-ing it back. Two defects in one line, and both bit production on 2026-08-15: 1. The path it wrote was ~/.neuron/engram/snapshot.json — the engram server's CANONICAL store. A READ route overwriting the persistence owner's canonical file. This defect had been fixed once (export moved to a scratch path); it came back when the hand-written dispatch block was replaced by @route dispatch and the unfixed copy is the one that survived the merge. 2. Cost: a full snapshot write, a 128 MB read, and a parse of the whole graph, per request, to return a bounded slice. Calling it tonight overwrote the canonical snapshot and immediately preceded an engram crash loop. engram_edges_json(limit, offset) is the builtin that route's own TODO asked for ("Future: add an engram_edges_json() builtin and drop the file round trip entirely"). It walks g->edges directly and emits every persisted field. limit <= 0 defaults to 1000, not unbounded: this is the endpoint that fell over, and an unbounded default would preserve the failure mode under a new name. Callers page explicitly. Registered in codegen.el's builtin_arity (both plain and __ spellings) and wrapped in el_seed.c per the project's C-builtin recipe.Checkpointing pushes the ENTIRE resident graph through store_put_node and store_put_edge (see engram_store_checkpoint). Nodes were cheap: a durable-hash compare skipped unchanged records with zero page I/O. Edges had no barrier at all — struct comment at PgCache.barrier_on even says "node durable-hash barrier" — so every edge was rewritten on every checkpoint, and each rewrite runs the idempotency probe max_page_lsn_for_id -> btree lookup -> page_read. Edges outnumber nodes ~3:1 here (37,663 vs 13,436), so routine checkpointing degenerated into a FULL-STORE WALK in id order: random page access across the whole 2 GiB store, repeated, overwhelmingly to rediscover nothing had changed. LRU is worst-case under exactly that pattern — it evicts the page it is about to want — so once the page cache was smaller than the store, the walk collapsed into thrashing: 100% CPU, flat RSS, no forward progress, port never bound. That took the live engram down twice on 2026-08-15. The walk is the defect. Sizing the cache to survive it treats the symptom. Changes: - dh_edge_hash(): edge counterpart of dh_node_hash, with a kind discriminator byte so an edge can never collide with a node of the same id in the shared map. created_at/updated_at/last_fired are excluded deliberately: last_fired is touched by activation without changing what the edge IS, and folding it in would defeat the barrier on precisely the hot edges that most need it. - store_put_edge(): barrier check + dh_set on success, mirroring store_put_node exactly. - store_scan_edges(): seed the barrier map from on-disk truth at load, so the FIRST post-boot checkpoint already skips unchanged edges. store_scan_nodes already did this and its comment says why; edges were simply never done. Verified: with the exact configuration that killed production (ENGRAM_POOL_FRAMES=65536 -> 1 GiB cache against a 2 GiB store), the engram now boots clean and serves — LISTENING, 13,436 nodes / 37,663 edges, embeddings complete, 0.0% CPU, RSS 1.14 GiB (cache resting at its budget rather than thrashing against it). Same small cache, same store, no walk.Follow-on to the edge write barrier. That fix removed the full-store walk; this one makes the pool able to notice if anything like it happens again. WHAT WENT WRONG, precisely: the pool thrashed the live engram to a standstill twice on 2026-08-15 and said nothing. From outside it was indistinguishable from "busy loading" — 100% CPU, flat RSS, no output — so four wrong theories got tried (bad binary, corrupt snapshot, WAL replay, feature flags), each costing a deploy or a rollback. The whole time, hits/misses/evictions were already being counted in PgCache, and the struct comment read: /* stats (introspection only — never affect semantics) */ That comment was the bug. Self-measurement treated as decoration is why the pool could not correct itself and why no one outside could see what it was doing. A system that cannot read its own state cannot correct, and neither can anyone watching it. - pc_adapt_budget(): the loop, closed. Over a sliding window, evictions running at a large fraction of accesses WHILE reuse is real means the working set exceeds the budget — so grow it, geometrically, bounded by a LIVE re-read of physical memory. Evictions alone are not pressure (a scan evicts and never returns); evictions with reuse are. An explicit ENGRAM_POOL_FRAMES still wins — an operator override must not be silently overruled. - Budget derived, not declared. A constant cannot be right: 16 GiB of frames is arbitrary on a 48 GB host and suicidal on a 16 GB one. Even "60% of RAM at startup" is a guess about the future — it cannot know the store grew or the machine changed. Hence the live re-read. - pc_report(): ONE structured emission carrying the entire sensed state, through emit_log — El's existing telemetry, already exporting to OTLP. Deliberately not a function per stat, and deliberately not a bespoke /api/pool endpoint: both make observability something hand-written per noun instead of the uniform mechanism every component already has. - engram_pool_stats_json(): the same state readable live, wired through the normal builtin path (codegen arity + el_seed wrapper), so the pool can be observed in real time rather than reconstructed afterward from a stack sample. Verified: with the exact configuration that took production down (ENGRAM_POOL_FRAMES=65536 → 1 GiB cache against a 2 GiB store) the engram boots clean and serves — 0.0% CPU, 13,436 nodes / 37,663 edges, embeddings complete — and NO pressure event fires, because the barrier removed the walk that caused it. The controller is defense in depth; the barrier is the fix.The adaptive budget I added an hour ago could only grow, and grew toward a share of TOTAL ram (80%, ~38 GiB on a 48 GB host). That is a memory leak with extra steps: total never shrinks when other processes need memory, so the pool had no way to notice it was starving the machine it runs on. Deployed briefly; caught as memory pressure on the host. A control loop with only one direction is not a control loop. - pc_available_ram(): free + inactive + purgeable via host_statistics64 on Darwin, MemAvailable on Linux. Availability is the quantity that moves when the machine is under pressure; total is not. Returns 0 when it cannot be read, and callers then refuse to grow — a cache is never worth swapping the host, so unknown means no. - Growth is bounded by availability minus a free-memory floor (2 GiB default, ENGRAM_POOL_FREE_FLOOR_MB), not by total. The share-of-total ceiling stays as a second bound and drops 80% -> 50%. - pc_relieve_pressure(): the missing direction. On every eviction pass, if available memory is under the floor, hand back ~25% of held frames; the resident set follows on the next pass so the memory is actually returned rather than merely re-labelled. Counted as adapt_shrinks alongside adapt_grows so both directions are visible in the same report. - pc_default_cap() also clamps the STARTING budget to what is spare right now, so a cold boot on a loaded machine does not open at a size the host cannot afford. Verified on a 48 GB host: engram boots in ~30s, RSS settles at 2.22 GiB (the store's actual size, resident, not creeping), 0.0% CPU, 13,439 nodes / 37,670 edges, embeddings complete. Guard reports 9.71 GiB available against a 2.00 GiB floor — 7.71 GiB of headroom it is permitted to use and no more.The guard I added minutes ago checked swap availability as a level (avail < total/8 -> report zero available). That is the wrong signal, and the same host proved it twice within minutes: 47.65 / 48.00 GiB swap used, 2047 swapouts/s -> genuinely thrashing 26.67 / 28.00 GiB swap used, 0 swapouts/s -> healthy, 15.6 GiB free Both are ~97% "used". macOS grows swap files on demand and trims them lazily, so the level says almost nothing about now — it is a high-water mark. The level check calls the second state an emergency and starves the pool for no reason, which is its own failure mode: a guard that fires on healthy machines gets disabled, and then guards nothing. What separates the two is whether pages are moving. So sample the swapout counter across calls and judge the delta: - > 200 pages/s (~3 MiB/s) sustained outward paging => report zero available; callers refuse to grow and pc_relieve_pressure hands frames back. - The first call primes the baseline and reports no pressure. One sample cannot have a rate, and inferring one from a single reading is exactly the mistake this commit removes. Measured thresholds, not guessed: idle sat at 0/s, recovery burst hit 24,845/s while the compressor drained (transient, correctly not a growth decision since growth is only evaluated on eviction passes), and real thrash held ~2000/s. 200/s sits clearly above noise and far below either. The compressor-footprint subtraction stays: that RAM is genuinely spoken for regardless of paging rate.Implements the three primitives the test-framework design (DESIGN.md §6.5) requires for gating on growth curves: el_alloc_count, el_alloc_bytes, el_peak_rss. Registered in codegen's builtin_arity and wrapped in el_seed.c per the project's C-builtin recipe. WHY COUNTS AND NOT WALL-CLOCK: a growth-curve gate has to be a hard build failure, which means the signal cannot flake. Wall-clock needs warmup, statistics, and a quiet machine; on shared CI it is unusable as a gate. Allocation counts are perfectly deterministic — same input, same number, every machine, every run. Fit them against n and a complexity regression becomes a build failure with zero noise. All four runtime string allocators (el_strdup, el_strbuf, and their _persist variants) funnel every allocation the language performs, so instrumenting there counts everything. WHY BYTES AS WELL AS COUNT — this is not redundancy, it is the whole gate. Measured with two El programs, one allocating once per item, one rebuilding its accumulator each iteration: n linear allocs / bytes quadratic allocs / bytes 100 100 / 290 100 / 5,150 200 200 / 690 200 / 20,300 400 400 / 1,490 400 / 80,600 800 800 / 3,090 800 / 321,200 The quadratic program's allocation COUNT is exactly linear — identical to the healthy one. Counting allocations alone would have missed it completely. Bytes catch it: each doubling of n quadruples bytes (ratios 3.94, 3.97, 3.99 -> converging on 4.0, i.e. O(n^2)), while the linear case converges on 2.0. That shape — count linear, per-allocation size growing — is the classic accidental quadratic, and it is exactly elc's defect: quadratic allocation VOLUME, which the old shipped compiler paid in RSS (27 GB, OOM) and the rebuilt one pays in malloc/free churn (42s on 1.4 MB). Volume was the invariant across both; RSS and wall-clock were just the two ways it surfaced. el_peak_rss is exported for context and is explicitly NOT a gating signal — it is perturbed by allocator internals, the page cache, and the OS. Gate on the deterministic numbers; report the physical one. Counters are unsynchronised by design: this is measurement, and a lock would change the thing being measured. Exact on the single-threaded compile path, approximate under threads.THE BUG. str_char_code() and str_slice() each called strlen() on every invocation. The lexer walks source one character at a time, so every character access rescanned the whole remaining input: O(n) per character over n characters = O(n^2). el_val_t str_char_code(el_val_t s, el_val_t i) { ... int64_t n = (int64_t)strlen(str); // <- O(n), every call if (idx < 0 || idx >= n) return 0; return str[idx]; } HOW IT WAS FOUND. Not by reading code — by sampling the running process, which is the same method that resolved tonight's engram outage after four wrong theories. A geometric sweep of synthetic sources showed wall-clock rising 3.0x, 3.0x, 4.0x, 4.14x per doubling (converging on 4x = quadratic), and a stack sample put 779 of 779 samples inside lex(), every one bottoming out in _platform_strlen via str_char_code and str_slice. THE FIX. Remember the length instead of recomputing it. The subtlety is INVALIDATION: El strings are arena-allocated, so a freed pointer can be reused for a different string at the same address, and a naive pointer-keyed cache would hand back a stale length and read past the end of the new string — trading a performance bug for a memory-safety one. So entries carry a generation, a hit requires pointer AND generation to match, and every path that frees or mutates a runtime string bumps the generation: el_arena_pop, seed_request_end, __str_set_char. Stale entries cannot be believed; they miss and recompute. MEASURED, same host, same inputs: n(fns) before after 512 0.10s 0.01s 1024 0.37s 0.02s 2048 1.51s 0.03s 50x the compiler's own 422 KB source concatenated (DESIGN.md's 3.58s case): 3.55s -> 0.03s 118x The speedup GROWS with input size, which is the signature of removing a complexity class rather than a constant factor. After the fix each doubling adds ~0.01s: linear. CORRECTNESS, verified rather than assumed: - byte-identical output on every sweep input (n = 128..2048) - byte-identical output on the 422 KB compiler concatenation - byte-identical output on tests/runtime/string_test.el - self-hosting fixpoint byte-identical - new tests/runtime/str_cache_test.el: 17 assertions covering bounds, empty strings, negative indices, slice clamping, distinct strings not sharing a cached length, 1000 interleaved strings forcing cache-slot collisions, and a grown string not reporting its old length. All pass. This is the defect that made dist/soul.c a committed artifact: elc could not run in CI because it needed 24 GB+ and minutes. It needs neither now.el_val_t math_log(el_val_t f) { return el_from_float(log(el_to_float(f))); } el_val_t math_ln(el_val_t f) { return el_from_float(log(el_to_float(f))); } Both were natural log, so math_log and math_ln were the same function. log10(100) returned 4.605 instead of 2. Three sources already agreed it should be base-10 and were being contradicted by this one line: - runtime/math.el:55 "// math_log — base-10 logarithm." - el_seed.c:1278 __log_f -> log10() (the path math.el actually calls) - tests/native/test_math.el:133 asserts log10(100) == 2 FOUND BY THE NEW TEST FRAMEWORK ON ITS FIRST RUN (el #133). The assertion had been sitting in the suite the whole time; nothing could report it. The old harness printed "N passed, M failed" with no per-test detail, and half the suites were not compiling at all — so a failing assertion in a suite nobody could run was indistinguishable from no failure. That is the entire argument for the framework, demonstrated on day one: this is not a bug the framework introduced, it is a bug the framework made VISIBLE. Verified: tests/native/test_math.el goes 12/13 -> 13/13, math-log passing.import "../../NOPE/does_not_exist.el" compiled CLEANLY — exit 0, empty stderr, and a program silently missing everything it imported. resolve_imports did `fs_read(src_path)` and used the result without checking. fs_read returns "" both for "file is empty" and "file does not exist", so a typo, a moved file, or a relative path resolved from the wrong working directory all produced a successful build of nothing. It caused a real wrong conclusion during test-framework work: a bisection run from a subdirectory where ../../runtime/ did not resolve produced ELEVEN consecutive "successful" compiles that had included no runtime at all, and the results were believed before anyone noticed. Missing dependency, confident success — the same shape as a test suite reporting pass for tests that never ran, and as a benchmark reporting 0us because the optimiser deleted the loop. fs_exists separates the two cases, so a legitimately empty file still resolves to "" and is fine. A path that does not exist now prints the resolved path and exits 1, which is what build scripts check. Verified: - bad import: exit 1 (was 0), message names the resolved path - elc-cli.el still compiles, self-hosting fixpoint byte-identical - neuron's full soul amalgam regeneration: exit 0, 405ms, output byte-identical at 1,270,212 byteslet a: Int = 5 getint(5) == a -> str_eq(getint(5), a) SIGSEGV getint(5) == 5 -> getint(5) == 5 fine A function call whose return type codegen cannot infer poisoned the operator, and a declared Int on the other side did not save it. str_eq then read an integer as a char* and segfaulted. Only an integer LITERAL on one side forced the numeric form, which is why the bug stayed invisible: the common case happened to be safe. The check required BOTH operands to be provably Int: if is_int_expr(left) { if is_int_expr(right) { numeric } } Loosening to OR is strictly safer, not a trade: - when one side is a known Int, str_eq is ALWAYS wrong — it dereferences that integer — while numeric comparison is at worst a wrong answer on a program that was already ill-typed; - when neither side is Int nothing changes at all, so string comparison is untouched. Found by the test-framework agent while building the benchmark harness; it correctly declined to fix it mid-phase since it is a codegen semantics change. VERIFIED, because a semantics change earns more than an assertion: - 15/15 on a dedicated operator suite covering string literals, string vars, string-returning calls, mixed var/call, and != in every combination. The pre-change compiler scores 0/15 on the same file: it segfaults before printing anything. - self-hosting fixpoint byte-identical - the ONLY difference in the compiler's own generated C is the intended one: a nested if becoming two sequential ifs, in EqEq and NotEq. Nothing else moved. - neuron's full soul amalgam regenerates in 400ms, exit 0, output BYTE-IDENTICAL at 1,270,212 bytes - test_math 13/13, test_string 27/27, test_core 10/10, test_text 12/12 — 62 tests, 190 assertions, zero failures NOT fixed here, same family, flagged for a decision: Bool PARAMETERS are not tracked as int-like, so `cond == want` between two Bool params still lowers to str_eq and segfaults. Found while writing this commit's own test harness — the first version of it crashed on exactly that, on both the old and new compiler. It needs the same treatment, and it wants its own change.fn check(label: String, cond: Bool, want: Bool) -> Void { if cond == want { ... } -> if (str_eq(cond, want)) SIGSEGV } Bool has always been an integer in the value model — type_to_c maps Bool to "int", and el_runtime.h states "Bool -> el_val_t (0 = false, nonzero = true)". But Bool names were registered NOWHERE: build_int_names_for_params tracked Int and Float params, and the `let` path tracked Int and Float bindings. Neither knew about Bool. So comparing two Bools fell through to str_eq, which dereferenced 0 or 1 as a char* and segfaulted immediately. This is the third instance of one family found tonight, after el #137 (a call on either side of == poisoned the operator) and el #136 (a missing import compiled clean). All three are the same shape: something the compiler could not type, silently handled as a string. Found while writing #137's own test harness — the first version of that harness crashed on exactly this, on both the old and new compiler, which is how it surfaced. A test harness that cannot compare two Bools is a good way to notice. VERIFIED: - the harness that segfaulted on every prior compiler (exit 139, no output) now runs clean: 14 passed, 0 failed - self-hosting fixpoint byte-identical - the compiler's own generated C differs by 8 lines — only the intended registration - neuron's full soul amalgam regenerates in 424ms, exit 0, BYTE-IDENTICAL - test_math 13/13, test_string 27/27, test_core 10/10, test_text 12/12 Adds tests/runtime/operator_typing_test.el, the 15-case suite from #137, so this family is covered going forward rather than rediscovered.char* result = el_strdup_persist(e ? e->value : ""); // never freed pthread_mutex_unlock(&_state_mu); char* copy = el_strdup(result); // arena-tracked return el_wrap_str(copy); Two copies were made. `result` existed only as the source for `copy` — never returned, never freed — and el_strdup_persist bypasses the arena BY DESIGN ("state_set, engram internals"), so arena-pop could never reclaim it. Every state_get leaked its full value string, permanently. MEASURED: 200,000 state_get calls against a 64-byte value. before 15 MB peak RSS growth (~75 bytes/call — the value plus overhead) after 0 MB IMPACT. The soul's awareness loop has 68 state_get call sites and ticks every 200ms. Live measurement before the fix: RSS climbing 112 MB per 20s, about 19 GB/hour, in awareness_run -> one_cycle -> perceive, while node_count stayed flat at ~13,479 — growth with no data behind it. It drove the host from 20 GB free to 4.3 GB in roughly an hour. WHY NOW, since the code is old: the soul used to restart constantly (no write-through, divergent graph, 2.11 GB). Stabilising it (neuron #162) let it stay up long enough to accumulate. The fix did not cause this leak; it removed the crashes that were hiding it. Same pattern as the test framework surfacing math_log — the defect was always there, something finally made it visible. Found by Ishikawa rather than by reading the nearest code: method (arena push/pop IS correctly paired per tick), material (node count flat, so not data growth), environment (19 GB/hr / 18,000 ticks = ~1.1 MB per tick, so per-tick not one-shot), machine (an allocator that bypasses the arena) — which is where the evidence pointed. el_strdup tracks into the thread-local arena, which touches no shared state, so taking the single copy under _state_mu is safe and removes the temporary entirely. Verified: self-hosting fixpoint byte-identical; state round-trip correct for hit, miss, and overwrite.engram_think_json passed NULL as the anchor. NULL is not "no opinion": engram_think re-origins at `anchor ? anchor : region->centroid`, so NULL means "read from the centroid" — and the centroid is the one point where the gradient is zero by construction. r = x - centroid = 0, so every axis projection is 0, grad is 0, and direction takes the "at rest" branch at engram_cognition.c:137. Measured consequence: EVERY faculty returned an identical null result, differing only in its label — {"direction":[0,0,0,0,0,0,0,0],"spread":0,"magnitude":1,"confidence":0.5} magnitude 1 is membership evaluated at the centroid, spread 0 is its distance to itself, confidence 0.5 is the stance fallback. The geometry was never at fault: /api/drift computes real values (centroid_sep 0.104, core_disp 0.045) over the very same 87 members. Neuron could not think because the read was always taken from the region's own centre. The seeds choose WHICH region; they must also supply the VANTAGE. Anchor at the first resolvable embedded seed — the same seed eg_geo_build_desc infers dim from, so the two can never disagree. One seed still yields a real gradient because the descriptor expands to that seed's neighbourhood, so the seed's position is distinct from the neighbourhood centroid. The vector is COPIED, never borrowed: g->nodes is realloc'd in place on append, so a borrowed EngramNode* dangles across any concurrent write. Verified against a clone of the production store (13,616 nodes / 37,865 edges): self anchor n_support 87 magnitude 0.00282 spread 18.79 values hub n_support 28 magnitude 0.00318 spread 17.72 with distinct unit direction vectors. Previously both returned the zero vector with magnitude 1 and spread 0. STILL OPEN, now isolated by this fix: all five faculties return identical numbers and confidence stays 0.5, because cog_stance_init is passed NULL for the stance and the faculty enters the computation only through the stance's axis_gain[] and bias_dir. The faculty label is inert until a stance is loaded — which is what learn()'s correspondence-beat calibrates. Same shape as this bug: a neutral parameter collapsing a capability to a constant.The soul daemon had two engram callers and only one of them locked. soul.el:729 starts the HTTP server via http_serve_async (spawning http_worker threads); soul.el:731 then runs awareness_run() on the MAIN thread. awareness.el's perceive() -> engram_activate_json() -> engram_activate() -> eg_vindex_sync() -> vindex_insert() mutates the same g->nodes/g->edges and the process-global _eg_vindex HNSW index that the workers touch. g_engram_req_lock existed to serialize exactly this, but it was only ever taken inside http_worker: engram_req_lock/engram_req_unlock appear in ZERO .el sources, so the awareness loop ran lock-free beside the workers on every tick (SOUL_TICK_MS=1000). Result was a crash-loop under launchd KeepAlive: five crashes in ~4 minutes on 2026-08-16 with varying faulting frames -- search_layer<-vindex_insert <-eg_vindex_sync, engram_activate, abort, and one inside xzm_realloc's own freelist. Varying sites plus a fault in allocator metadata means heap corruption. The SIGSEGV address 0x65646f4e6d617267 is little-endian ASCII "gramNode": string bytes dereferenced as an Elem vector pointer. Diagnosed by bisection rather than inspection: - Replaying all 13,820 real dim-768 vectors harvested from the live store through the index single-threaded under ASan is 100% clean, which rules out an HNSW logic/bounds bug. - Two threads on one index trip ThreadSanitizer immediately at engram_vindex.c:195 (visited_reset), reached from both vindex_search and vindex_insert. VIndex keeps a SHARED visited-epoch scratch buffer, so even two concurrent READS corrupt each other's traversal and walk bogus element indices. So this is purely a concurrency defect, not an HNSW logic error. (An inspection-derived hypothesis about an out-of-bounds reverse-link write at engram_vindex.c:340 was disproved by the single-threaded run.) Fix: a thread-local ownership depth (_eg_req_depth) lets engram entry points self-guard. engram_activate() becomes a wrapper over engram_activate_inner() that acquires g_engram_req_lock when called with depth 0 (the awareness thread) and passes through when depth > 0 (nested inside an http_worker that already holds it), so the non-recursive mutex cannot self-deadlock. The depth is a plain counter, never a recursive-mutex count, preserving engram_self_reify_beat_json's contract of genuinely releasing the lock mid-beat.Promotes the two throwaway sanitizer harnesses used to diagnose the 2026-08-16 soul crash into engram/test/ so the bug cannot silently regress. The harness has two halves and the PAIR is the point — it is what localises the defect to concurrency rather than to HNSW logic: single 3000 clustered vectors, one thread, ASan+UBSan. The CONTROL. Must always be clean. During diagnosis this cleared all 13,820 real dim-768 vectors from the live store, which DISPROVED an inspection-derived hypothesis about an out-of-bounds reverse-link write at engram_vindex.c:340. concurrent writer + reader on one shared index, TSan. Currently reports a race at engram_vindex.c:195 (visited_reset) reached from both vindex_search and vindex_insert, because VIndex still owns its visited[]/visit_epoch scratch — so even two concurrent READS corrupt each other's traversal. Verified: half 1 passes, half 2 reproduces the race. Gated on EXPECT_RACE, default 1, so the concurrent half documents the known defect without failing the suite today. When the visited set moves to a per-query checkout pool (hnswlib VisitedListPool style — NOT thread_local, since http_worker is a thread per connection and a __thread buffer would leak ~55KB per connection), flip EXPECT_RACE=0 and it becomes a real gate.El's units of encapsulation are the function and the module. Neither can hold a concern that belongs to the process, so each one had been expressed the only way it could be -- as a convention: call this at every site. Conventions of that shape do not hold. Measured here: zero process-identity guards at any layer, 20 environment variables each with its default written inline at the read site, 62 persist call sites, 10 per-route auth checks. One absence, four times. Step 0 first, because the premise was wrong. El was believed to have no middleware or effect mechanism. It has one, and it is already load-bearing: codegen injects engram_boundary_beat at the entry of every @manager/@accessor fn, decorators take arguments and stack, dharma_emit from a non-@manager fn is a #error, and the cgi block injects el_cgi_init at the head of main(). So the correct move was not to invent a mechanism but to generalize the seam that already existed. The real gap is narrower and is now recorded: the seam is prologue-only and its callee is a fixed builtin. Adds a `program` block -- the third program-level declarative block. cgi and service declare what a program may do; program declares what it is. program "engram" { singleton: "engram" env ENGRAM_BIND: String = ":8742" env GUIDE_PORT: Int = "8771" } singleton takes an exclusive flock before any user statement runs and refuses a second start, reporting the holder's pid. It is a lock rather than a pidfile so the kernel releases it on death including SIGKILL -- no stale state, and so no "delete the lock file to get unstuck" ritual, which would itself be a convention. It reports the pid because "already running" is not actionable; a pid is. That is the direct answer to a stale process surviving a pkill and going on answering probes. env entries resolve once at startup -- environment wins, declaration supplies the fallback -- and validate as a whole, reporting every problem at once rather than costing one restart per variable. config("X") for an undeclared X is fatal, because an advisory schema is just another convention. Programs without a program block are unaffected, so migration is per-program. Only one keyword is added. `config` and `env` could not become keywords -- both are real identifiers in the tree -- so the block's fields are read as identifier token values by its own parse loop and stay usable everywhere else. The init function is emitted at the block site and called from main() rather than inlined into main(). The live backend is codegen_streaming, which emits in source order and cannot hold the entry list alive until main(); this way only a single bool has to survive. Also fixes: config() was defined in el_runtime.c but never prototyped in el_runtime.h, so any el program calling it failed to compile under C99. Spec: section 18 documents what shipped. Section 9 is corrected -- it claimed decorators had no structural meaning, which has not been true for some time. Section 19 designs durability-as-an-epilogue-effect and route authorization and states plainly why neither is implemented here: both land in files under concurrent modification, and the prerequisite for both is lifting the seam from prologue-only to prologue/epilogue. Self-hosting fixpoint verified byte-identical.Migrates engram to the `program` block. 18 configuration variables that each carried their default inline at the point of use now declare it in one place, and engram declares itself a singleton. The read sites lose their defaults entirely: `let v = env("X")` followed by `if str_eq(v,"") { "default" } else { v }` collapses to `config("X")`. The guide_env_or(key, dflt) helper is deleted -- its whole job was supplying a per-site default, which is the thing being removed. Fixes ENGRAM_DATA_DIR, which was the clearest instance of the defect. It was read at six sites. Five were dead: `let dir_raw = env("ENGRAM_DATA_DIR")` immediately shadowed on the next line by `engram_resolve_data_dir()`. The sixth was live and defaulted to /tmp/engram, contradicting the canonical resolver's $HOME/.neuron/engram -- and its consumer is the pre-destructive reseed backup, so with ENGRAM_DATA_DIR unset the safety copy was written to ephemeral storage while the store it protected lived elsewhere. All six now go through engram_resolve_data_dir(). ENGRAM_DATA_DIR is deliberately NOT declared in the program block, and the source says why: engram_resolve_data_dir() already owns it, and a second declaration would give it two owners that can disagree -- recreating the exact defect being removed here. A variable belongs in the block when the block would be its only owner. HOME stays a raw env() read; it is an environment fact, not configuration. singleton: "engram" matters more than it looks. Today a second engram whose bind() fails merely returns from http_serve -- after it has already replayed the WAL and written boot-time backup files -- and then exits 0, indistinguishable from a clean run. That is how two instances came to share one data dir. Verified that the second instance now refuses before any side effect: with instance 1 holding the lock (lsof pid, shell pid, and lock file contents all agreeing at 5946), the second start named that pid, exited 1, and left the data directory untouched. Verified by bijection on the generated C: 18 config() reads, 18 declarations, no read without a declaration and no declaration without a read. Three bad Int values are reported in a single run rather than costing one restart each. ENGRAM_API_KEY keeps its permissive empty default, which disables auth -- that is pre-existing behaviour and changing it is out of scope. The source marks making it `required` as the obvious hardening follow-up.engram_ground_json resolved each seed to a REGION, wrote the grounded-by edge between the two regions' HUBS, and then echoed those hubs back in the "claim"/"evidence" fields as if they were the caller's input: const char* cid = C->hub_id ? C->hub_id : EL_CSTR(claim); const char* eid = E->hub_id ? E->hub_id : EL_CSTR(evidence); cog_ground_edge(g_engram_store, cid, eid, grounding, fw); Three consequences, all measured against a clone of the live store: 1. The edge landed on a node the caller never named. Grounding 3b9ced5d against 6edf8c79 wrote an edge on the hubs of their regions instead. 2. When both seeds resolve into the same region the support is circular and scores near 1.0 for structural reasons, not evidential ones. Four probe nodes written together landed in one region, and every grounding among them returned 0.93-0.99 as if it were evidence. Two independent agents hit this and reported 0.885 / 0.909 self-groundings as confident. 3. The echo concealed both: the response was indistinguishable from a successful grounding of the ids that were passed in. The region is HOW a claim is evaluated; it is not WHAT the claim is about. So the edge now attaches to the requested ids, and the resolved hubs are reported separately as claim_region / evidence_region. Degeneracy is broader than hub == hub. Three circular shapes, all previously invisible: same-region both seeds resolve to one region claim-region-is-evidence the evidence IS the hub of the claim's own neighbourhood — measured at 0.98883 evidence-region-is-claim the mirror case Each sets grounding to 0 and writes no edge. Circular support is not support, and a grounding that is degenerate by construction must not enter the graph as though it were evidence. Verified: 6edf8c79 -> 6edf8c79 degenerate=same-region g=0 written=false 6edf8c79 -> d0406dfd degenerate=same-region g=0 written=false ebc1413e -> 64cc96ef degenerate=false g=0.774563 written=true 64cc96ef -> ebc1413e degenerate=false g=0.802896 written=true Legitimate grounding across distinct regions is unchanged and still writes; only circular support is refused. This is the same class as #142 and #146 — a value that looked like an answer with nothing behind it — except here it was also writing that non-answer into the canonical store.A relation that keeps holding up strengthens; one that stops corresponding decays. That is not analogous to grounding, it IS grounding — so it belongs on the edge, not in a subsystem beside it. The graph was already the grounding structure; this stops modelling it as something else. Deleted, not refactored: - cog_ground_edge and the `grounded-by` relation type. A grounded-by edge models grounding as a relation BETWEEN nodes when it is a property OF a relation. #147 fixed which endpoints that edge landed on and left the wrong idea intact. Measured on the live store: the old path scored two nodes with ZERO edges between them at 0.925237 and wrote an edge for it. - ground() writing. It was a read that wrote — the eg_vindex_sync defect. Three identical calls produced three writes to the same edge id. - keystone_write_blocked. Its measured cost was 0.00% brier reduction over n_trials 0 on the keystone: the loop never ran, so the self was never calibrated and never falsifiable. Nothing replaces it — non-circularity of the reference frame is temporal, not a permission. - a graph predicate for "evidence downstream of itself", built and then withdrawn. Reachability from the self region covers 89.2% of the live graph (10,580 of 11,861 nodes), so any topological predicate marks nearly all evidence tainted and degenerates into the total block censorship began as. The vector, carried in a GRD1 block on the edge's own metadata: factual, relational, associative (the existing hebb), polarity (SIGNED — near zero is "no support", negative is "actively contradicts"; `inhibitory` is that distinction crushed to one bit), provenance class, and a timestamp. Confidence, recency, staleness and volatility are DERIVED at read and never serialized. Decay is one model, not two: cog_decay_factor is the single implementation and engram_temporal_decay now delegates to it — proven bit-identical over 24 (age, reinforcement) points. Values reference: thirteen regions, aggregate MIN, binding value named. Measured — the 13 have pairwise centroid cosine min 0.1525 / mean 0.5199 / max 0.9278, so they demonstrably are not one region, and a mean would let agreement with twelve mask a violation of the thirteenth. Supersession versions the whole vector jointly, gated by consequence and salience with no epsilon anywhere: floor crossings and sign changes only. Polarity flips and provenance-class changes are inherently significant and bypass the salience gate. Also fixed: the frame contract. Descriptors are built over L2-normalized member embeddings; think() and the grounding path were fitting RAW vectors against them. Measured on the self region, same data, same 106 members: magnitude 0.00283443 -> 0.536134, spread 18.7565 -> 0.930163. Every fit score sat three decimal places below the 0.5 floors that gate on them. assert() gates on both floors and computes still_held instead of returning a hardcoded `true` — the old build reported still_held for a node that does not exist.There was no SIGPIPE handling anywhere in this runtime: no signal disposition, no MSG_NOSIGNAL, no SO_NOSIGPIPE, and send() called with bare flags. The default disposition of SIGPIPE is to TERMINATE THE PROCESS, so any client that hangs up mid-response takes the whole engram with it. MEASURED, and it is not hypothetical. Production has restarted 254 times since 2026-08-13T19:37 at a flat ~10 minute cadence: 17:05:18 17:15:29 17:25:38 17:35:50 17:46:00 17:56:10 18:06:22 18:16:30 Intervals of 10m09s-10m12s, not 10m00s. That excess is the whole story: ai.neuron.engram-tick has StartInterval 600, and engram-tick.sh:13 calls curl -s -m10 -X POST .../api/tick The beat does not finish within 10s over 13,634 nodes, so curl waits its full timeout and closes. The engram then writes the tick response to a dead socket, takes SIGPIPE, and dies. launchd KeepAlive restarts it, so the failure presents as a mysterious restart rather than a crash — and ~/.neuron/logs/engram.log records nothing but "[http] listening on" 254 times, with no exit reason. launchctl list confirms the last exit as -13. Root cause is one level out: consolidation had no owner, so an external ticker was created to poke it, and the ticker is what kills it. The fix here does not address that; it makes the process survivable while it is addressed. Two layers, because neither alone is portable: - SO_NOSIGPIPE per accepted socket (Darwin/BSD) and MSG_NOSIGNAL per send (Linux), so the signal is never raised for socket writes at all. - A process-wide SIG_IGN backstop, installed once and idempotent, for platforms and paths with neither. With the signal ignored, send() returns -1/EPIPE and the existing error path closes the connection. Also retries send() on EINTR, which the previous loop treated as fatal. This is an exemption in the sense of lang/spec §8: the write never checked whether the peer was still there, and the consequence of not checking was fatal rather than merely wrong.co_registration is corr(hebb strength, semantic proximity) over a region's internal edges. Whether use and meaning agree is a property of EACH EDGE; the correlation averages it into one scalar per region, so a region holding one violently disagreeing edge beside one violently agreeing edge reports ~0. The disagreements cancel and the summary destroys exactly what it was built to reveal — the mean-versus-min error, in different clothes. Measured: 375 live neighborhoods, 340 positive, 31 AT ZERO, 4 negative. Read as a count that says 'four things to be curious about'. Read correctly it says four were lopsided enough to survive averaging, and the 31 zeros are where opposing sites cancelled. The loop computing the aggregate already had both halves per edge — w and cs — and threw them away. Now: discord = z(semantic proximity) - z(association strength) standardized within the region from accumulators already gathered. No second statistic, no constant, no threshold; |discord| IS the nucleation strength. >0 near in meaning yet unlinked by use; <0 linked by use yet far in meaning. Both surprising. This also removes the reason curiosity looked like a search problem. With a per-region number the only way to find sites is to enumerate regions — I wrote exactly that sweep, and it is a supervisor walking the structure, O(n) per call, fine at 375 and impossible at a million. Nothing in a mind scans its neighborhoods to find what is surprising; the surprise captures attention. That sweep is reverted here. co_registration is deprecated, not deleted: it is embedded in the persisted GEO1 blob and removing it is a format migration that must not ride along. Nothing new may read it.There is no write node. What arrives at /api/write is a SIGNAL; a node is an OUTPUT of realization, never an INPUT to it. route_write asserted otherwise in one line: let manifold: String = "[" + body + "]" // the body IS a valid manifold node object A request body is not a manifold, and that assertion is the whole defect. It is why every written signal landed as one flat node with zero edges, measured on a clone: {"inserted":1,"nodes_added":1,"edges_added":0} and GET /api/neighbors on the new id returning []. PR #155 corrected transduce(signal, modality) to return a Manifold — components plus relations — but touched only ingest, the runtime and its tests. Nothing downstream called it: grep 'transduce|realize|Manifold|decompos' over engram/src/server.el returned exactly one line, a comment. The primitive was fixed and the engram's entire HTTP surface never reached for it. This wires the intake seam to the primitive that already exists. It decomposes nothing itself and must never: transduce dispatches through the dlsym realizer registry, so adding a modality is registering a realizer, not editing this file and not patching the runtime. intake_signal only carries what the primitive returns into the store — components become nodes carrying their OWN geometry via node_attach_geometry, relations become edges at the weight the realizer stated, and manifold_member still wires the set into one connected sub-graph exactly as insert_manifold_json already did. Built general rather than special-cased: five of the six intake doors (write, supersede, nodes, knowledge/capture, state-events) are the same hand-written "content -> engram_node_full -> one flat node", differing only in the node_type/tier/tags they hardcode. Those are parameters here so each door can move onto this one function. Only /api/write rides it in this pass. When no organ is registered the signal is stored flat exactly as before, but the response now says so ("realized":false,"organ":false,"components":0). Silent flattening was the real defect — a caller could not tell "nothing decomposed me" from "I decomposed into one component". el_runtime.c draws the same line between an absent organ and a broken one, for the same reason. No realizer is authored here and none is registered, so production behaviour is unchanged. The mechanism is what landed.el_runtime.c was created 2026-05-03 as an explicitly temporary build shim. It was deleted that afternoon ("runtime is 100% native El") and restored 25 minutes later "UNTIL the compiler is updated to emit #include el_seed.h". The `until` never came. 3.5 months on it is 20,527 lines, and nothing was ever set up to notice — a file scheduled for deletion gets no owner, no budget, no boundary. What kept it growing is not inertia, it is an instruction. lang/AGENTS.md said el_runtime.c "is the authoritative single-file link target ... THIS IS WHERE A NEW C BUILTIN'S IMPLEMENTATION MUST CURRENTLY LIVE TO BE LINKABLE", and made it step 1 of the add-a-builtin recipe. That is false. Placement is a link-time concern: builtin_arity maps NAME -> ARITY INT only, the El name is emitted as the exact C symbol, and `ld` resolves it — the compiler cannot tell which .c a symbol came from. `nm lang/dist/platform/elc` on the shipped compiler already shows T _engram_geo_reify_index_new, T _vindex_insert, T _engram_think, T _engram_reason_abduce: it is linked from ten translation units today. In a repo where agents write most of the code, a false instruction in the instruction file is the forcing function. The file grew because the recipe said to grow it. The multi-file runtime is therefore already real, and the docs and the distribution never caught up — which left a live, shipped bug: * Linking el_runtime.c alone FAILS at `ld` (undefined engram_ground_json, engram_activate_inner, eg_find_relation, cog_assert_two_axis, ...) because el_runtime.c #includes six engram headers and calls into all six siblings. * sdk-release.yaml shipped el_runtime.c/.h + engram_store.c/.h and none of the other five required .c files, so downstream consumers of the el-runtime-c Artifact Registry package and of install.sh got a lib/ that cannot link. * .githooks/pre-commit linked el_runtime.c alone with stderr to /dev/null, so it reported all 13 native suites as FAILED with the real ld error invisible. * AGENTS.md's self-host recipe compiled el-compiler/runtime/el_runtime.c — a path the same file's "DO NOT EDIT" list names as a lagging fork. The root fix is to stop writing the list down eight times: * lang/runtime/SOURCES — the canonical link set, in one place, in link order. * scripts/el-runtime-sources.sh — prints it, optionally prefixed; --check fails loudly on a missing file, --headers for the shipped headers. * Every link line in AGENTS.md, lang/AGENTS.md, DESIGN.md, lang/spec/language.md, the three workflows and the pre-commit hook now reads that one list. * Adding a concern's .c is one line in SOURCES, so a new builtin no longer has to be appended to el_runtime.c just because appending was the cheaper edit. Distribution: ship the siblings rather than amalgamate. Amalgamation needs a new tool and contradicts DESIGN.md's compile-once-link-many; the siblings are already independently authored and independently tested (engram/test/*.sh link subsets directly), and engram_store.c was already shipped, so this completes a mechanism that existed rather than inventing one. Source is also a superset: a consumer that wants one file can concatenate, one that wants separate TUs cannot undo an amalgamation. el-runtime-c/-h stay for backward compatibility; el-runtime-src is added carrying the complete set plus SOURCES. lang/AGENTS.md now points new C builtins at the concern-owning .c and states plainly that the compiler cannot tell which .c a symbol came from, with the nm evidence. AGENTS.md's "reconcile which is canonical (verify)" note is resolved: neither file supersedes the other, the canonical unit is the set. Verified locally (the bar; not CI): * engram/src/server.el compiles and links against the SOURCES set. * Compile-once-link-many into libel.a links the same program. * elb builds from the corrected recipe. * Self-host fixpoint byte-identical (11,110 lines, stage2 == stage3) built with the SOURCES-driven link line. * pre-commit hook: 0 of 13 native suites passing -> 8 of 13. The 5 still-failing suites are PRE-EXISTING and untouched here: test_fs (fs_list_json undeclared), test_state (state_has, state_get_or undeclared), test_json (json_build_array/json_build_object/json_escape_string undefined), test_time (now_ns undefined), test_env (1 assertion). Builtins registered in builtin_arity with no implementation or no declaration anywhere — the same recipe defect, now visible because the linker error is no longer suppressed. Not attempted: making elc emit #include el_seed.h and dropping elb's hardcoded runtime path. That is the correct long-term fix and finishes the 2026-05-03 migration, but it touches codegen and self-hosting and belongs in its own change.scripts/check-single-runtime.sh guards against el_runtime.c being COPIED — it was written after a lagging fork shipped to prod and dropped learned hebb edges. Nothing guarded against it GROWING. So it grew: 10,607 -> 20,527 lines, 94% in 3.5 months, the whole time under an explicit commit-message promise that it was a temporary shim about to be deleted. Worse, the copy guard was never wired in. Its own footer described the CI wire-in as a TODO, and the TODO had never been done — the script existed but ran nowhere, in no workflow and in no hook, so it had caught nothing for as long as it has been in the tree. A guard that does not run is a comment. This adds the missing guard and runs both. * lang/runtime/BUDGET — a RATCHET, not a limit. max_lines is set at the current 20,527 with NO headroom: the file cannot grow by one line. A second cap, max_engram_fns (279), counts top-level engram_/eg_/cog_ definitions in it — ~47.5% of the file is engram code and engram already owns six sibling .c files, so this is the scoreboard for moving it out. Both may only go DOWN. * scripts/check-runtime-growth.sh — enforces the ratchet, and three invariants that keep the multi-file runtime honest: every .c in lang/runtime/ is either in SOURCES or explicitly platform-optional (an unaccounted .c is compiled by nothing and is silently dead); install.sh's hardcoded download list matches SOURCES (it cannot call the helper — it runs where there is no checkout — so that copy is checked, not trusted); and an advisory nudge to lower the budget when you have earned it. * Both guards now run as early steps in ci-dev.yaml, ci-stage.yaml and sdk-release.yaml, and in .githooks/pre-commit. The failure message is the point. The guard that existed said what was wrong but not where the code should go, which makes it easy to "fix" by arguing with the guard. This one names the destination: the concern-owning .c, or a new .c plus one line in SOURCES, or c_source in a program's manifest.el — and it prints the `nm` command that proves placement is link-time and that the shipped compiler already links from ten translation units. Every runtime file except el_runtime.c is deliberately uncapped, because that is where code is supposed to go. Proven with negative controls, per lang/AGENTS.md step 5 — each shown FAILING: * +1 line to el_runtime.c -> FAIL (20528/20527) * +1 engram fn, net-zero lines -> FAIL (280/279) * a new unaccounted lang/runtime/*.c -> FAIL * engram_store.c removed from install.sh -> FAIL, names the missing file * el_runtime.c truncated to 20,000 lines -> PASS + "lower max_lines to 20000" * baseline, tree unmodified -> OK, and both guards green el_runtime.c is byte-identical after the controls; this commit changes zero lines of it.First concern moved out of el_runtime.c under the ratchet, and the move is deliberately small: it exists to prove the mechanism end to end before anything large depends on it. engram_text.{c,h} — query tokenization, candidate-token hygiene, word-boundary matching, and the text-damage signature. Four functions, moved verbatim; only `static` was dropped and each doc comment travelled with the code. They touch no EL value type and no engram store type: plain C over <ctype.h>/<string.h> over char buffers. They were never el_runtime.c's business. el_runtime.c 20,527 -> 20,427 lines (BUDGET max_lines ratcheted down) engram fns 279 -> 275 (BUDGET max_engram_fns ratcheted down) The Stage 1 extension point worked as designed: adding the file to lang/runtime/SOURCES was one line, and every build path picked it up. The Stage 2 drift guard then caught that I had NOT added it to install.sh's standalone list — the exact class of drift it was written for, on its first real change, before the commit rather than after a broken SDK shipped. WHY ONLY 100 LINES, AND WHAT ACTUALLY BLOCKS THE REST Measured, not estimated: of 273 engram-domain functions in el_runtime.c (~9,700 lines), only 75 (~1,058 lines) can move today, and they are scattered rather than clustered. The blocker is a single fact: EngramNode, EngramEdge, EngramStore, EngramLayer, EngramWal and EngramIdSlot are typedef'd INSIDE el_runtime.c. No sibling can see them. engram_store.h defines a SEPARATE serializable "node view" struct and maps between the two. So every engram function that takes an EngramNode* — which is most of them, 109 of 273 by direct type reference — cannot compile in engram_store.c until those types move to a shared header. That extraction is the real Stage 3 enabler and it deserves its own change: it touches the most load-bearing struct in the system, and doing it in the same commit as a code move would make a regression impossible to bisect. REPAIRED: 10 engram harnesses that had silently stopped linking Not new breakage from this move — verified against unmodified dev, where el_runtime.c + engram_store.c alone already failed with undefined symbols. They had been dead for as long as el_runtime.c has been calling into the siblings, and nothing noticed because nothing ran them. run_m3_parity, run_m7_traversal, run_m35_hebb_persist, run_interoception_p0..p5 — now build from $(scripts/el-runtime-sources.sh) run_wal_tests — its two TUs #include "el_runtime.c" directly, so it links the SIBLINGS ONLY; adding el_runtime.c to that link line would define every symbol twice (That #include'd .c is worth recording: the runtime does have one, in engram/test/test_wal.c and the generated test_failloud.c.) Verified locally — every one of these was run, not assumed: * m3_parity ............ PASS, incl. ASan+UBSan clean across seed/on/reboot * m7_traversal ......... PASS * m35_hebb_persist ..... PASS (the gate over the original prod hebb bug) * interoception p0..p5 . PASS (all six) * wal_tests ............ 66 passed, 0 failed, + fail-loud exit check * self-host fixpoint ... byte-identical, AND the emitted C is byte-identical to the pre-move compiler output — the move changes nothing the compiler produces * engram/src/server.el . compiles and links * native suites ........ 8 of 13, unchanged from before the move; the same 5 pre-existing failures, no regression * both runtime guards .. green at the new, lower budget Also fixes a block comment left unterminated by the extraction (the deleted range carried its closing */), restoring the compile to its single pre-existing -Wcomment warning.The beat reported which function crossed a boundary, never which decorator put the beat there. So the graph accumulated boundary events with no attribution, and no construct could be measured — "is this decorator earning its keep" stayed an argument instead of a traversal. engram_boundary_beat now takes the construct and carries it on the bus as {"construct":"..."}. The injection point, the beat, and the accumulation already existed; only the attribution was missing. Also pins a known defect as a test: codegen calls fn_has_decorator for exactly three names (manager, accessor, route). Twelve others parse, attach, and compile to nothing — including @authenticate (6 uses), @authorize (3), @rate_limit (3) and @validate (2), which look like protection and are not. decorator-authenticate-compiles-to-nothing asserts that @authenticate emits byte-identical C to no decorator at all, so fixing it will be a visible flip. Verified: compiler self-hosts byte-identically, 86/86 native compiler tests pass, emitted C carries the construct for both @manager and @accessor.codegen called fn_has_decorator for exactly three names — manager, accessor, route. Twelve others parsed, attached as {name,args}, and compiled to nothing, including four that look like protection: @authenticate (6 uses), @authorize (3), @rate_limit (3), @validate (2). The cause was not that the branches were untidy. A construct had nothing to BE, so its meaning had nowhere to live except the emitter, and every construct was therefore a compiler edit. A name -> injection table would have moved the enumeration twenty lines up without removing it. So the construct now carries its own meaning: @decorator("injects_at_entry", "engram_boundary_beat") fn audited() {} @audited fn risky_op() -> Int { ... } // gets the beat, attributed to "audited" scan_declared_decorators is a token-level pre-pass beside scan_routes, forced by streaming codegen having no whole-program AST. manager and accessor are seeded as the compiled-in core — the fixedSelf shape from substrate.go: a complete fallback exists, declaration is enrichment. This is the injection half of the seam only. The prohibition half (@manager's #error on dharma_emit) stays hardcoded, because "which calls may appear inside this boundary" is a query over program structure and there is nothing yet to ask. Verified three ways: emitted C for existing @manager/@accessor code is byte-identical to the hardcoded path; a construct with a name the compiler has never heard of injects correctly; the compiler self-hosts byte-identically. 90/90 native compiler tests pass.@authenticate (6 uses), @authorize (3), @rate_limit (3) and @validate (2) parsed, attached, and compiled to nothing. Fourteen applications that read as protection and emitted no instruction — a function decorated @authenticate compiled byte-identically to an undecorated one. The missing capability was not authentication. It was that a construct could observe a boundary but never refuse one. injects_at_entry discards the target's result; there was no form in which a construct could say no. @decorator("guards_at_entry", "my_auth") fn authenticate() {} @authenticate @authorize fn handler() -> String { ... } emits, at entry: { el_val_t __g = my_auth(EL_STR("handler"), EL_STR("authenticate")); if (__g) return __g; } { el_val_t __g = my_roles(EL_STR("handler"), EL_STR("authorize")); if (__g) return __g; } Guards precede injections because a refused call must not report a crossing, and every guard runs where the topmost injecting construct wins — refusal is not a role, so it does not follow the role convention. The compiler still knows nothing about auth. The program points the construct at its own function, which is where that decision belongs. Verified: existing @manager/@accessor output byte-identical, compiler self-hosts byte-identically, guards stack in declaration order and emit before the beat. 94/94 native compiler tests pass.§6 records 62 persist-after-mutate sites, 10 auth-per-route, and index-after-append that failed at 9 of 9 — every one an obligation at a crossing that decayed into "remember to do this afterwards." An obligation a human must remember is not an obligation, and the 9-of-9 figure is what that costs. @decorator("injects_at_exit", "persist_now") fn durable() {} The body moves into a static helper and the visible fn becomes a wrapper, so EARLY RETURNS pass through the exit injection. Emitting it only before the fall-through return would have silently missed every early return — the exact failure class this seam exists to remove. Fns with no exit construct emit byte-identically to before. Three independent constructs now compose on one fn, none known to the compiler: el_val_t mutate(el_val_t k) { { el_val_t __g = my_auth(EL_STR("mutate"), EL_STR("authenticate")); if (__g) return __g; } engram_boundary_beat(EL_STR("mutate"), EL_STR("manager")); el_val_t __r = __el_body_mutate(k); persist_now(EL_STR("mutate"), EL_STR("durable"), __r); return __r; } Guard, then entry, then body, then exit. §5.2 asked whether `hold` is one construct or two; the implementation answers one construct with two faces, selected by declared kind rather than by two mechanisms. Verified: existing output byte-identical, compiler self-hosts byte-identically, early returns pass through the exit, ordering holds under composition. 98/98 native compiler tests pass.ROOT CAUSE of the weaker design: "C has no closures" was taken as a fact about what is possible. It is a fact about one grammar. Every C++ lambda, every Go closure, every Rust closure compiles to a struct of captured values plus a function pointer -- which is what is emitted here. Codegen emits C; it is not written in C's syntax, and the distinction is the whole difference between a construct that can only decide whether to repeat and one that controls invocation. It would also have crippled the JS backend, which has closures natively, for a limit that applies only to the C one. PREDICTIONS AND RESULTS 1 env struct + thunk taking void* TRUE 2 fails to compile: struct redefinition FALSE -- C allows the inner declaration to shadow. Prediction wrong; C is more permissive than assumed. A different real defect surfaced instead: a wrap with no exit construct emitted `(EL_STR("f"), EL_STR(""), __r);` -- a call to an empty target -- because has_exit was reused as "needs a wrapper" and the exit line was emitted unconditionally. Fixed. 3 compiles when the target is declared in El FALSE -- and this is the root cause worth keeping: El has ONE type, el_val_t = int64_t. El's type system cannot describe a callable, so `extern fn` and the real signature cannot be made to agree in El's own vocabulary. The fix is not a cast: codegen DEFINES the wrap calling convention, so codegen emits the extern declaration. The convention is not El-expressible; it is emitted. 4 target controls invocation, 0..N times TRUE 5 existing @manager output byte-identical TRUE 6 compiler fixpoint holds TRUE 7 emitting the convention makes it compile TRUE MEASURED base(5) wrapped by a target that invokes the body twice and sums -> 10 never_runs(5) wrapped by a target that never invokes it -> 999 Neither is expressible by "decide whether to repeat". This supersedes the repeats_body experiment on experiment/repeats-body, which was built around the mistaken limit.The other half of a boundary: not what runs when something crosses, but what may not cross at all. It was two string literals in vbd_is_restricted_name and one #error in cg_fn — one prohibition, uneditable without a compiler release. @decorator("prohibits_outside", "raw_sql") fn repository() {} fn sneaky() -> Int { raw_sql("DROP") } // #error "boundary violation: raw_sql may only be called from an // @repository fn, but 'sneaky' is not one" The recursive matcher is parameterised through a state key rather than by threading an argument through every branch of the walk — the mechanism codegen already uses for __match_counter and __if_expr_counter. Each prohibition is checked in its own turn, so the owning construct is known by construction and the diagnostic names it instead of hardcoding one rule's wording. PREDICTIONS AND RESULTS 1 the 3 duplicated uniqueness rules are textually identical TRUE 2 a declared prohibition reproduces @manager's #error TRUE 3 existing output byte-identical TRUE 4 a program can declare its own prohibition TRUE 5 fixpoint holds TRUE I misread result 2 on first pass: a @manager fn calling dharma_emit still emitted one #error, which looked like a failure. It is the CAPABILITY-tier rule at codegen.el:2578, a separate prohibition system, and it fires identically on the pre-change compiler. MEASURED DEFECTS STILL OPEN - two independent prohibition systems (VBD constructs, capability tiers); only the first is declarable - 3 uniqueness rules written 6 times, once per codegen path, kept in sync by hand and identical today 102/102 native compiler tests pass, compiler self-hosts byte-identically.HYPOTHESIS (Will's): a compiler whose one compiled mechanism is extending the LANGUAGE — not the compiler — can compose without recompilation. ISHIKAWA — why does a construct require a recompile today? method codegen inlines the target call into the body machine the binary has no table to consult material the declaration lives in source, read at compile time measurement nothing observes what applied at runtime root cause the crossing is resolved at EMISSION, not at EXECUTION CHANGE: codegen emits one unconditional indirection per fn. Which constructs apply is read from a table that can be written AFTER the binary exists; targets resolve through dlsym against the running image. PREDICTIONS AND RESULTS P1 a construct declared after the build applies TRUE P2 an unlinked target is skipped, not fatal TRUE P3 emitting on every fn is measurably slower FALSE — 0.37s -> 0.36s with 267 indirections and no bindings. Free unused. P4 the compiler still self-hosts TRUE (see note) DEMONSTRATED: an El program with NO decorator in its source, already compiled and linked, picked up a construct declared afterwards: $ /tmp/seamrun -> 7 $ echo 'work audited entry audit_entry' > constructs.txt $ EL_CONSTRUCTS=constructs.txt /tmp/seamrun AUDIT: work applied by audited 7 P4 note: my first fixpoint test was wrong, not the code. I compared gen1 to gen2, which must differ whenever codegen's output changes. gen2 == gen3, 267 seam sites, stable. MEASURED COST, and the root cause was not where I looked 0 bindings 0.36s vs 0.37s baseline free 2 bindings, dlsym per call 2.45s 6.6x 2 bindings, resolved once 0.69s 3.5x recovered The table scan was never the cost. dlsym walks the dynamic symbol table on every call. Resolve once and cache — which is the smallest form of what salience does for memory: what is hot stays resolved. The 0.69s residual is audit_entry's own printf on two of the compiler's hottest functions, not seam overhead. CONSEQUENCE: the five compile-time declaration kinds on iteration-1 are a compile-time specialisation of something that resolves at runtime. They are not wrong, but they are not the mechanism — the mechanism is one indirection, and a kind is data.Prediction 3 was FALSE. I expected refusal to be impossible through the seam because the entry indirection discarded its return. One line: { el_val_t __s = el_seam_run(EL_STR(f), 0, 0); if (__s) return __s; } work() returns 7; bound to a refusing construct AFTER the build it returns 42. So three of the five compile-time kinds are runtime-bindable: entry injection, exit injection, and refusal. wraps_body needs invocation control and prohibits_outside is compile-time by nature. 104/104 native compiler tests pass.ISHIKAWA: why did exit injection still need compile-time knowledge? Because the body-helper wrapper was only emitted when codegen already knew an exit construct existed. The wrapper being conditional was the cause, not the wrapper being necessary. PREDICTIONS AND RESULTS P1 exit becomes runtime-bindable TRUE returns 14, bound after the build P2 codegen shrinks TRUE 5094 -> 5044 P3 cost 5-15% from a call frame on every fn FALSE 0.37s -> 0.38s, ~3% P4 fixpoint holds TRUE Every fn now gets a body helper and a wrapper. It has to be unconditional: early returns must route through something for an exit construct to observe them, and codegen cannot know which fns will be bound after the binary exists. Removed with the machinery: declare_exit, decorator_exit, cg_exit_target, cg_exit_construct, and the injects_at_exit scanner branch. Two controls failed and were rewritten rather than repaired -- no-exit-construct-emits-no-wrapper asserted the optimisation this removes, so it is now inverted. The integration harness gained a seventh assertion: an exit construct declared after the build replaces the result. 99/99 native, 7/7 integration, fixpoint gen2==gen3.ISHIKAWA: why did wraps_body need compile-time knowledge? Because the wrapper called the target directly. If the wrapper calls through the seam instead, the seam can call the body itself, and a construct bound after the build decides how and whether to invoke it. PREDICTIONS AND RESULTS P1 wrap becomes runtime-bindable TRUE body x3 -> 21, never invoked -> 111 P2 codegen shrinks TRUE 5042 -> 4977 P3 cost 5-10% from an indirect call on every fn TRUE 0.36s -> 0.39s, ~8% P4 zero-param fns break on the empty struct TRUE empty struct is a GNU extension, empty init is C23. Fixed with a char field. P5 fixpoint holds TRUE PROCESS FAILURE worth recording: my first patch silently did not apply because I dropped the assert on the string replacement. The build then failed with "undeclared identifier __thunk_noargs", which I nearly attributed to the empty-struct prediction. The guard that would have caught it existed and I removed it -- the same shape as every other defect found tonight. Removed: declare_wrap, decorator_wrap, cg_wrap_target, cg_wrap_construct, params_to_call_args, and the wraps_body scanner branch. prohibits_outside is now the ONLY construct kind left at compile time, and it cannot move: a #error has no runtime.I said prohibition could not move because "a #error has no runtime". That conflated two separable things: WHEN a violation is detected (build time -- correct, and unchanged) and WHERE the rule and the checker live (the compiler -- assumed). A prohibition is a containment relation over the call graph. So codegen now records what it saw: sneaky calls raw_sql allowed calls raw_sql allowed calls @repository repository calls prohibits:raw_sql and tools/check/prohibitions.sh decides, at build time, outside the compiler. PREDICTIONS AND RESULTS P1 codegen can emit the call graph it already walks TRUE P2 the check becomes a query outside the compiler TRUE P3 all prohibition decisions leave codegen TRUE zero #errors now P4 violations still caught at build time TRUE exit=1 P5 codegen drops below the 4661 baseline FALSE 4962, +301 P5 is the finding. The TRAVERSAL is irreducible -- you must walk the AST to find calls, and those ~120 lines do not move no matter who decides. What is not irreducible is the rule (which names) or the decision (#error). Those left. I predicted the whole 223 lines would go because I had not separated walking from adjudicating. Still compiled, and measured rather than assumed: the capability-tier system (cap_check_call, is_self_formation_call, is_dharma_call, is_llm_call, cap_record_violation, emit_cap_violations) is 76 lines of the same shape -- prohibits_WITHIN rather than prohibits_outside, so the checker needs the opposite polarity to absorb it. 98/98 native, 4/4 prohibition_query.sh, 7/7 seam_binding.sh, fixpoint ok.codegen.el carried builtin_arity(): 344 lines, 300 entries, a hand-maintained second copy of el_runtime.h. PREDICTIONS AND RESULTS P1 the table duplicates the header TRUE 243 shared names P2 they have already drifted FALSE ZERO drift. The duplicate had been maintained correctly. P3 codegen can emit call-arity relations TRUE P4 the check becomes a query against the header TRUE P5 codegen drops to roughly baseline TRUE 4903 -> 4512, 149 BELOW the 4661 it started at P2 being false is the better result: the table was not WRONG, it was INCOMPLETE. 110 functions the runtime declares had no entry, so calling them with the wrong argument count produced no El-level diagnostic at all. Measured: the old compiler reports 0 arity errors for __http_do_map_to_file(1); the query reports "takes 5 arguments, called with 1". Deriving from the header fixes coverage AND makes drift impossible by construction. 503 signatures, versus 300 entries maintained by hand. THREE DEFECTS IN MY OWN CHECKER, each found by running it rather than reading it 1. El names and C names differ -- `println` is `__println`. 60 of 500 decls carry the prefix and codegen owns the mapping; the old table carried both keys. One rule covers all 60. 2. Multi-line declarations parsed as zero params, so the checker reported "takes 0" for a function taking 5. A diagnostic with the wrong number in it is worse than none -- the same shape as the stale caller attribution in the previous pass. 3. Fixing (2) by joining lines dropped 500 signatures to 334, because a declaration preceded by a comment no longer started its record. Comments are stripped first now. 98/98 native, 5/5 arity_query.sh, fixpoint ok.This block is structurally unlike the previous four. It does not only adjudicate, it DISPATCHES: Instant + Duration must become el_instant_add_dur, LocalDate + Duration must become el_local_date_add_dur. The emitted C depends on the type answer, so it cannot move to a post-hoc query. Selecting which call to emit is an emitter's actual job. PREDICTIONS AND RESULTS P1 the block conflates dispatch with adjudication TRUE P2 adjudication can move, dispatch cannot TRUE P3 this pass shrinks codegen far less than the last TRUE, and worse: 4513 -> 4537, it GREW by 24 lines P4 the rules are affine algebra, closed by construction TRUE P5 no type propagation -- name tracking plus a hardcoded list of which builtins return which type TRUE, 19 names P3 is the honest result and it is not spun: moving 19 names into a data file cost more lines than it saved, because a generic loader is larger than the enumeration it replaces. The win is not line count. It is that adding a 20th temporal builtin is now a one-line edit to signatures.rel instead of a compiler change, and that the data is inspectable. WHY THE HEADER CANNOT SUPPLY THIS, unlike arity: el_runtime.h declares every builtin as returning el_val_t, because El has ONE type. That single type is why the whole seam is cheap and it is exactly why the C boundary cannot say that now() returns an Instant while unix_seconds() returns an Int. The El-level type is real and the boundary erases it. INCOMPLETE, and stated rather than hidden: P2 said adjudication could move to a query. It has NOT. Violations still emit TIME_TYPE_ERROR inline from the emitter. Only the type DATA moved. Moving the adjudication needs the operand types recorded as relations, which is a further pass. 98/98 native, 4/4 temporal_signatures.sh, fixpoint ok.The previous pass moved the type DATA and left the judgment inline, which I stated rather than hid. This finishes it. PREDICTIONS AND RESULTS P1 codegen can emit operand-type relations TRUE "main calls temporal:instant_plus_instant" P2 the affine rules are a small closed set as data TRUE 6 rules P3 violations still caught at build time TRUE exit=1 P4 the reporter leaves codegen TRUE 4538 -> 4507 P5 the TIME_TYPE_ERROR placeholder must STAY TRUE P5 is the boundary of this whole approach. The emitter has to emit SOMETHING for an illegal expression -- it cannot emit nothing and it cannot decide what the program meant. So the placeholder is irreducible in the same way the AST traversal was: what moved is the judgment and the wording, not the fact that something must be written. The rules are affine algebra and the set is closed because there are only two kinds of thing. An Instant is a POINT, a Duration is a DISPLACEMENT: add a displacement to a point, subtract two points for a displacement, combine displacements. Nothing else is meaningful, which is why the enumeration in temporal.rel cannot grow the way an allowlist does. A defect in my own checker, found by running it: the .rel file uses aligned columns and my awk assumed a single space, so the message came out with the rule key still prefixed. Same class as the multi-line header parse in the arity pass -- formatting assumptions that only fail when you look at the output. 98/98 native, 6/6 temporal_query.sh, fixpoint ok.PREDICTIONS AND RESULTS P1 is_int_call's 35 hardcoded names move to data TRUE P2 is_int_name stays -- it is annotation propagation TRUE P3 the dispatch stays -- it is emission TRUE P4 codegen shrinks ~40 lines TRUE 4507 -> 4469 P5 the design doc's characterisation is WRONG TRUE P6 the moved data also fixes the bug it exposed TRUE P5 CORRECTS THE RECORD. el-language-design.md and geometry-vs-code.md both cite "== lowering to str_eq unless both operand names are in a hardcoded int-name set -- a literal list of variable names treated as integers" as the paradigm defect. It is not one. __int_names is populated from TYPE ANNOTATIONS (param["type"] == "Int"), which is primitive but legitimate type propagation. The actual defect was is_int_call: 35 hardcoded builtin return types, the same shape as the temporal 19. P6 IS A LIVE CORRECTNESS BUG, PRE-EXISTING, NOW FIXED let a = str_len("hello") // no annotation let b = str_len("hi") let c = a + b // -> el_str_concat(a, b) on two integers Verified identical on the pre-change compiler, so not a regression. It compiled clean, ran, and printed NOTHING where it should print 7. No error at any layer. The repair is three lines: an unannotated let takes its type from what the initialiser returns. The return types were already required for dispatch and were simply never consulted at the binding site. Moving them into data is what made the gap visible -- reading the code for eight hours did not. 98/98 native + 2 new, 31/31 integration, fixpoint ok.Both, at different layers -- and it is the same split as serialization: the convention is the BASIS, never the ACT. lexeme -> token `fn` means function-start because someone said so CONVENTION shape recognition given tokens, which construct is this REGION source -> structure parsing is transduction onto that basis GEOMETRY byte traversal something must read them in order IRREDUCIBLE Three things push the ACT toward region rather than convention: ambiguity (a * b needs context; a grammar resolves it with the lexer hack, a region by neighbourhood), error recovery (nearest-region is free), and precedence, which is ordering along an axis with a conventional parameter. AND THE SHOULD GATE SAYS NO TO THE OBVIOUS MOVE Every other table this session moved to data. This one stays code. The keyword set is CLOSED by the language definition -- it does not leak the way an allowlist does -- and the lexer runs before the program is understood, so a program can never declare its own keywords. Externalising it costs file I/O on every compile and buys nothing. Same verdict as is_digit in ASCII. WHAT WAS ACTUALLY WRONG: five of 46 keywords were consumed by no parser or codegen path. sealed, activate, seed, protocol, impl. Each stole an identifier from users for nothing. SECOND SILENT MISCOMPILATION OF THE DAY. Using one did not fail to parse: let seed = 42 let impl = seed + 1 compiled CLEAN -- zero cc errors -- and printed 0 instead of 44. No diagnostic at any layer. Fixed by removing the five. A DEFECT IN MY OWN MEASUREMENT, caught before it did damage: my first pass checked only parser.el and reported `test` as inert too. codegen consumes it at 4135 for --test mode, and the tree has 408 uses. Removing it would have broken every test in the suite. The measurement was re-run across all four consumers. 100/100 native + 2 new, 31/31 integration, fixpoint ok.3a position, or a convention we agreed on? c48db6c2a8Both, at different layers, and the split is the same as everywhere else. The NUMERAL is convention -- int_to_str was already form 1, because no position determines that twelve is written 1 then 2 in base ten. The NUMBER is a position: three things are three things regardless of notation. But the sharper answer follows from `love = 0`. A bare `3` is a MAGNITUDE WITH NO AXIS. It is not a position until something gives it a direction, which is exactly why 3.days needs a calendar and why time_add(t, n, "min") had to carry its axis as a string. PREDICTIONS AND RESULTS P1 numeral = convention, number = position TRUE P2 a bare literal is dimensionless until context types it TRUE P3 there is a measurable place where El guesses TRUE P4 Instant + Int is not caught though Duration + Int is TRUE P5 the rule catches it TRUE P6 nothing legitimate in the tree relies on it TRUE P3/P4 IS THE DEFECT, and it was found by reasoning from the philosophy and then measured. Duration + Int was refused -- "an Int carries no unit" -- while let t: Instant = now() let u: Instant = t + 3 compiled to raw (t + 3) and reported CLEAN. Adding a dimensionless number to a point is worse than adding it to a displacement: it silently moves the instant by an unspecified amount. 3 of what? Whatever the representation happens to be, which is the leak itself. The asymmetry had no justification; the rule was simply never written. P6 MATTERED. Two calendar tests looked like Instant + Int: let later: Instant = i + 1.hour let later: Instant = base + 15.hours They are not. `1.hour` lexes to a Duration -- el_duration_from_nanos(1LL * 3600000000000LL) -- and both stay clean. That is the whole answer demonstrated in one line: t + 3 is refused because 3 has no axis; t + 1.hour is accepted because .hour supplies one. 104/104 native + 2 new, integration green, fixpoint ok.The question is premature, and measuring says why. El's partition is a FILESYSTEM PATH, not a neighbourhood, and there is no namespacing at all. MEASURED import is textual inlining (resolve_imports), guarded against double inclusion by a __elc_imp__:<path> state key when a .elh header exists the header is inlined instead and the .el is marked seen, so symbols resolve at C link time -- so linking IS real, delegated to C two modules defining `helper` emit two C functions into one translation unit So linking barely survives the PATH partition. Whether it survives a neighbourhood partition cannot be asked yet. A DIAGNOSTIC REGRESSION I CAUSED, found by asking this question. cc does catch the collision, but reports: error: redefinition of '__el_body_helper' error: redefinition of '__env_helper' error: redefinition of '__thunk_helper' error: redefinition of 'helper' The user's own function is FOURTH. The first three are generated symbols introduced by the unconditional-wrapper pass earlier today -- before it, there was one clear message. Repaired by catching the collision at El level instead: duplicate definition: 'helper' is defined 2 times — El has no namespacing, so imported modules share one global scope LIMIT, stated rather than hidden: textual inlining destroys file provenance. By the time codegen runs there is one source string, so the message can say WHICH name collides but not which files. Naming a.el and b.el needs provenance threaded through resolve_imports. 104/104 native, 4/4 definitions_query.sh, the compiler itself reports clean, fixpoint ok.The module question ended with a limit: textual inlining destroys file provenance, so a duplicate-definition message could name the symbol but not the files. Threading it exposed a bigger absence first. TOKENS HAD NO POSITION AT ALL. A token was a flat (kind, value) pair, so NO diagnostic in El could name a place -- every error named a symbol and never a line. That is the prerequisite the module question was resting on. THE CHAIN, end to end lexer counts newlines; tok_append mints (kind, value, line) parser stride 2 -> 3; tok_line added; FnDef carries its line codegen records <fn> defines_at:<line> resolve_imports publishes <file> spans <start> <end> for the combined source checker maps a combined line back to file:line-within-that-file duplicate definition: 'helper' is defined 2 times — El has no namespacing, so imported modules share one global scope /tmp/modtest/a.el:1 /tmp/modtest/b.el:1 PREDICTIONS AND RESULTS P1 15 stride sites, encapsulated in tok_kind/tok_value TRUE, but see below P2 adding a line field is mechanical TRUE P3 the lexer must count newlines TRUE P4 resolve_imports can record per-file line ranges TRUE P5 the message can then name both files TRUE P6 token memory grows TRUE, 25.0 -> 33.9 MB (+36%) FOUR DEFECTS, EACH FOUND BY RUNNING AND NOT BY READING 1. interp_tokens_append_all walks the token list DIRECTLY with its own copy of the stride. Gen1 built fine and gen2 emitted corrupt C, because the compiler's own source uses string interpolation. My search missed it because I grepped for the variable name `tokens`; it is called `dst`/`result`. Searching by name instead of by shape -- third time today. 2. tok_count in test_compiler.el carried the stride too. I had scoped the search to compiler sources and it had escaped into the tests. 3. Nested resolve_imports calls accumulated spans into shared state, so each republished meaningless line ranges under the parent's name. Making the buffer local fixed it; guarding the WRITE did not, which is what I tried first. 4. The first working version reported b.el:3 -- the COMBINED line against a filename that has no line 3. A file:line that does not match the file is worse than no line at all. 105/105 native, 37/37 integration, fixpoint ok, compiler self-checks clean.cycles/ one file per Ishikawa -> scientific method -> Six Sigma loop, named for the DEFECT not the fix, carrying the commit record as written at the time findings/ what the cycles produced, cross-cut: live bugs, architecture answers, and defects in my own measurement The organising finding is that predictions which came back FALSE produced every significant result. Eleven of sixty-one failed, and those eleven found: that the arity table was not drifted but 40% incomplete; that the AST traversal is irreducible and only rules and judgments move; that guards could refuse through the seam after all; and that routing el_bin_lookup through the gate did NOT fix the SIGSEGV, because the fallback strlen was the hazard -- a wrong fix I would otherwise have shipped as verified. One cycle was run without committing predictions first and had to be discarded as rigged. It is kept, in full, as 18-async-half-expressible.md.ISHIKAWA: three silent miscompilations found the same day shared one shape. method type tracked by per-function name sets, fed from annotations machine el_val_t erases everything at the C boundary material no propagation through expressions measurement nothing verifies an annotation against what it annotates root cause El has type ANNOTATIONS and no type CHECKING. The annotation feeds dispatch and is never itself verified. MEASURED, and it is not merely a wrong answer let x: Int = "hello" ; x + 1 -> printed 4343631981, a string POINTER interpreted as an integer let s: String = 42 ; println -> dereferenced address 42 The first leaks a raw memory address into program output. The second is an arbitrary-read primitive if the integer is ever attacker-influenced. PREDICTIONS AND RESULTS P1 let x: Int = "hello" compiles clean TRUE P2 let s: String = 42 compiles clean TRUE P3 the annotation drives dispatch, unverified TRUE P4 same root cause as all three bugs found today TRUE P5 checking literal-vs-annotation catches both TRUE P6 zero false positives across the compiler's source TRUE The emitter only RECORDS the mismatch; tools/check/annotations.sh decides, consistent with every other check landed today. INCOMPLETE, stated rather than hidden: only literals are checked. let x: Int = some_string_fn() still passes, because signatures.rel carries Int/Instant/Duration and no String entries. That is a DATA gap, not a capability limit -- every El function declares its return type in source and codegen already holds ret_type on every FnDef. 105/105 native, 5/5 annotation_query.sh, fixpoint ok.