Give cross-cutting concerns an owner instead of a convention #145
Reference in New Issue
Block a user
Delete Branch "fix/cross-cutting-concerns"
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?
Step 0 first: the premise was wrong
I was asked to confirm or refute "el has no middleware/effect mechanism" before building on it. It is refuted. El has a boundary seam, it is already load-bearing, and I found it by reading
lang/el-compiler/src/rather than inferring from code shape:codegen.elcg_fn()injectsengram_boundary_beat(<fn name>)at the entry of every@manager/@accessorfunction. The source comment states the intent exactly: "auto-emit at the decorated-fn boundary … so a decorated op self-reports with ZERO hand-written instrumentation in its body." That is middleware.@route("/p","GET") @manager fn f()), parsed into adecoratorslist.dharma_emitfrom a non-@managerfn emits a#errorinto the generated C — compile-time policy, not lint.cgiandserviceare program-level declarative blocks;cgiinjectsel_cgi_initat the head ofmain(), and the block kind drives capability enforcement.So the correct move was not to invent a mechanism but to generalize the one that already existed. Inventing a parallel one would have duplicated working machinery.
The real gap is narrower, and is now recorded in the spec: the seam is prologue-only, and its callee is a fixed builtin chosen by the compiler rather than derived from the decorator. That is precisely why the two concerns needing an epilogue (durability) or per-route policy (auth) degraded into 62 and 10 hand-written call sites.
Also:
lang/spec/language.mdwas stale. It claimed "Decorators with structural meaning today: none" and listed VBD enforcement as planned; both have been implemented for some time. It did not mentionservice, decorator arguments, stacking,@route, orengram_boundary_beatat all. Corrected here.What this adds
One new keyword. A
programblock — the third program-level declarative block.cgiandservicedeclare what a program may do;programdeclares what it is.Process identity
singletontakes an exclusiveflockinjected as the first statement ofmain()and refuses a second start, reporting the holder's pid.SIGKILL, so there is no stale-lock state and therefore no "delete the lock file to get unstuck" ritual, which would itself be a convention.pkill -fand going on answering probes.Configuration
enventries resolve once at startup — environment wins, declaration supplies the fallback — then validate as a whole, reporting every problem in one run rather than costing one restart per variable.config("X")for an undeclaredXis fatal, because an advisory schema is just another convention. Programs with noprogramblock are unaffected, so migration is per-program.Only
programbecame a keyword.configandenvcould not — both are real identifiers in this tree (lang/runtime/env.el:70,epm/src/registry.el:23). The block's fields are read as identifier token values by its own parse loop, so they stay usable everywhere else.Evidence
Singleton, on the real engram binary — instance 1's pid agrees across three independent read-backs (
lsofon the bound port, the shell's job pid, and the lock file contents, all 5946):The refused instance left the data directory empty — it refused before any side effect. That is the contrast that matters: today a second engram whose
bind()fails merely returns fromhttp_serve, after replaying the WAL and writing boot-time backup files, and then exits 0 — indistinguishable from a clean run. That is how two instances came to share one data dir.SIGKILLthe holder and the next start succeeds with no cleanup, proving the no-stale-state property.Configuration — three bad values, one run:
Migration completeness, by bijection on the generated C — 18
config()reads, 18 declarations, nothing read without a declaration and nothing declared without a read.Self-hosting fixpoint verified byte-identical, before the rebase and again after rebuilding against merged
dev.test_compiler82/82.The ENGRAM_DATA_DIR bug, fixed
The clearest instance of the defect. Read at six sites. Five were dead —
let dir_raw = env("ENGRAM_DATA_DIR")immediately shadowed on the next line byengram_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 withENGRAM_DATA_DIRunset the safety copy was written to ephemeral storage while the store it protected lived elsewhere.It is deliberately not declared in the
programblock, 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. The rule recorded in the spec: a variable belongs in the block when the block would be its only owner.Designed, deliberately not implemented
lang/spec/language.md§19 specifies durability as an epilogue effect (@durable, with wrapping rather than prefixing, coalescing N mutations into one fsync, and a#errorfor a mutating fn that lacks the decorator) and request auth as a route effect (auth:on@route, checked before dispatch, defaulting torequired— defaulting to public would preserve the current silent-forget failure mode, and a default that preserves the defect is not a fix).Not implemented because of collision, not difficulty: both land in
el_runtime.candengram/src/server.elpaths under concurrent modification by the VIndex and geometry/transducework. Editing files whose correctness is currently under repair is how you get a false conclusion about whose change broke what. The prerequisite for both — lifting the seam from prologue-only to prologue/epilogue — is independent of both collisions and can land first.Corrections to the brief's premises
neuron/soul.elandneuron/routes.eldo not exist in this repo. Zero hits forsoulanywhere in the worktree. Soul lives in a separate repo, so its 10 env vars were not migrated. Engram has 20 distinct env vars, not 12 (18 configuration +ENGRAM_DATA_DIR+HOME).engram.memguarddoes not exist. Zero hits repo-wide; the binary isengram.lang/AGENTS.mdsays link onlyruntime/el_runtime.c, but the link now also needsel_seed.cplus the engram translation units.README.mdis worse — it referenceslang/el-compiler/runtime/, which does not exist.Note on the bootstrap binary
lang/dist/platform/elcis re-stamped, becauseserver.elnow declares aprogramblock the previously committed compiler cannot parse — without it the tree is source the repo's own compiler rejects. This is the documented re-stamp fromBOOTSTRAP.md, and its precondition (verified fixpoint) is met.Two pre-existing test failures are unchanged and are not from this work — confirmed by rebuilding them against the original runtime:
test_env's "state_keys returns JSON array" fails identically before and after, andtest_json/test_statefail to link on symbols never prototyped (json_build_array,state_has) — the same class of gap asconfig(), which this branch fixed because it blocked the build.Follow-ups worth filing
ENGRAM_API_KEYkeeps its permissive empty default, which disables auth entirely. Pre-existing; making itrequiredis a behaviour change and out of scope, but it is the obvious hardening.http_servereturning instead of exiting on bind failure is still there.singletoncovers the duplicate-instance case, but a genuine port conflict still exits 0.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.