Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe820928b0 | |||
| 385c18442d | |||
| cace6a5ebf | |||
| d41645388a | |||
| 8a307dfd42 | |||
| 616815b2ab | |||
| 1a8a966cb3 | |||
| 1f70b9fa18 | |||
| 317466e8f7 | |||
| eb3e6d7c1f | |||
| 88e3008735 | |||
| 26af149aa1 | |||
| c18abf799c | |||
| b305b49f40 | |||
| 8ae163e8e5 |
+83
-36
@@ -10,10 +10,60 @@
|
||||
// cc -std=c11 -O2 -lcurl -lpthread -o engram server.c el_runtime.c
|
||||
// ./engram
|
||||
//
|
||||
// Configuration via environment:
|
||||
// ENGRAM_BIND — host:port (default :8742)
|
||||
// ENGRAM_API_KEY — bearer auth (optional)
|
||||
// ENGRAM_DATA_DIR — snapshot location (default ~/.neuron/engram)
|
||||
// Configuration is DECLARED, not scattered. See the `program` block below:
|
||||
// every knob's type and default lives there and nowhere else, is resolved from
|
||||
// the environment (env wins, declaration is the fallback) and validated before
|
||||
// any statement of this file runs. Read one with config("NAME") -> String.
|
||||
//
|
||||
// The one deliberate exception is ENGRAM_DATA_DIR — see the note in the block.
|
||||
|
||||
// ── Program declaration (cross-cutting concerns) ──────────────────────────────
|
||||
//
|
||||
// singleton: two engram processes against one data dir is data loss, not a
|
||||
// warning. The runtime takes an exclusive flock at startup and a second start
|
||||
// is refused loudly with the holder's pid.
|
||||
//
|
||||
// NOT declared here, on purpose: ENGRAM_DATA_DIR. Its resolution is owned by
|
||||
// engram_resolve_data_dir() (el_runtime.c), which defaults to $HOME/.neuron/engram
|
||||
// and fails LOUD rather than silently persisting to an ephemeral directory.
|
||||
// Declaring a default for it here as well would put the data dir's fallback in
|
||||
// two places — which is precisely the defect this migration removes (until
|
||||
// 2026-08-15 the reseed backup path carried its own "/tmp/engram" default that
|
||||
// disagreed with the resolver, so the pre-destructive safety copy landed in /tmp).
|
||||
// HOME is likewise not declared: it is a genuine environment read, not a knob.
|
||||
program "engram" {
|
||||
singleton: "engram"
|
||||
|
||||
// ── Core server ──
|
||||
env ENGRAM_BIND: String = ":8742"
|
||||
// Default "" leaves auth DISABLED (check_auth_ok short-circuits to true on an
|
||||
// empty key). That is the pre-existing behaviour and is deliberately preserved
|
||||
// here; making this `required` is the obvious hardening follow-up, but it is a
|
||||
// behaviour change and out of scope for this migration.
|
||||
env ENGRAM_API_KEY: String = ""
|
||||
|
||||
// ── Feature flags (bool-ish Strings; the predicate fns below own truthiness) ──
|
||||
env ENGRAM_STORE: String = "off"
|
||||
env ENGRAM_WAL: String = "off"
|
||||
env ENGRAM_AUTOCONNECT: String = "off"
|
||||
env ENGRAM_ISE_OFFGRAPH: String = "off"
|
||||
|
||||
// ── ISE telemetry ──
|
||||
env ENGRAM_ISE_RETENTION_MS: Int = "172800000"
|
||||
|
||||
// ── Guide (local Qwen3 via llama-server) ──
|
||||
env GUIDE_ENABLE: String = "off"
|
||||
env GUIDE_TIER_FORCE: String = ""
|
||||
env GUIDE_CACHE_DIR: String = ""
|
||||
env GUIDE_RAM_GB_4B: Int = "16"
|
||||
env GUIDE_RAM_GB_1P7B: Int = "8"
|
||||
env GUIDE_BACKEND: String = "llama-server"
|
||||
env GUIDE_HOST: String = "127.0.0.1"
|
||||
env GUIDE_PORT: Int = "8771"
|
||||
env GUIDE_LLAMA_SERVER_BIN: String = "llama-server"
|
||||
env GUIDE_NGL: Int = "99"
|
||||
env GUIDE_CTX: Int = "4096"
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -133,7 +183,7 @@ fn route_text_health(method: String, path: String, body: String) -> String {
|
||||
// engram_store_enabled() in el_runtime.c EXACTLY (1 / on / true). Default off →
|
||||
// every persistence path below is byte-for-byte the historical snapshot behavior.
|
||||
fn store_on() -> Bool {
|
||||
let v: String = env("ENGRAM_STORE")
|
||||
let v: String = config("ENGRAM_STORE")
|
||||
if str_eq(v, "1") { return true }
|
||||
if str_eq(v, "on") { return true }
|
||||
if str_eq(v, "true") { return true }
|
||||
@@ -162,7 +212,6 @@ fn persist_canonical() -> Int {
|
||||
if store_on() {
|
||||
return engram_store_checkpoint()
|
||||
}
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
// (2026-08-10 self-review) This returned a hardcoded 1, which made every
|
||||
// caller's `let saved: Int = persist_canonical()` a dead variable — six
|
||||
@@ -176,7 +225,7 @@ fn persist_canonical() -> Int {
|
||||
// per-write full-snapshot behavior. When ON, structural mutations append O(1)
|
||||
// WAL records instead of rewriting the whole graph, with threshold compaction.
|
||||
fn wal_on() -> Bool {
|
||||
str_eq(env("ENGRAM_WAL"), "on")
|
||||
str_eq(config("ENGRAM_WAL"), "on")
|
||||
}
|
||||
|
||||
// autoconnect_on — ENGRAM_AUTOCONNECT. Will's rule: "we shouldn't be inserting
|
||||
@@ -184,7 +233,7 @@ fn wal_on() -> Bool {
|
||||
// edge (kNN over embeddings) so no content node enters the graph edgeless.
|
||||
// Default OFF -> byte-identical to prior behavior (node created, no auto edges).
|
||||
fn autoconnect_on() -> Bool {
|
||||
let v: String = env("ENGRAM_AUTOCONNECT")
|
||||
let v: String = config("ENGRAM_AUTOCONNECT")
|
||||
if str_eq(v, "1") { return true }
|
||||
if str_eq(v, "on") { return true }
|
||||
if str_eq(v, "true") { return true }
|
||||
@@ -197,7 +246,7 @@ fn autoconnect_on() -> Bool {
|
||||
// separate state-event log tier instead of the node graph. Default OFF -> ISEs
|
||||
// remain graph nodes exactly as before (with 48h prune).
|
||||
fn ise_offgraph_on() -> Bool {
|
||||
let v: String = env("ENGRAM_ISE_OFFGRAPH")
|
||||
let v: String = config("ENGRAM_ISE_OFFGRAPH")
|
||||
if str_eq(v, "1") { return true }
|
||||
if str_eq(v, "on") { return true }
|
||||
if str_eq(v, "true") { return true }
|
||||
@@ -394,7 +443,6 @@ fn route_scan_nodes(method: String, path: String, body: String) -> String {
|
||||
// process ever booted with a partial/empty store, the first read request
|
||||
// clobbered the good snapshot. Read routes must never write the canonical path.)
|
||||
fn route_scan_edges(method: String, path: String, body: String) -> String {
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
let snap_path: String = dir + "/.scan-export.json"
|
||||
engram_save(snap_path)
|
||||
@@ -555,7 +603,6 @@ fn route_forget(method: String, path: String, body: String) -> String {
|
||||
|
||||
fn route_save(method: String, path: String, body: String) -> String {
|
||||
let p_raw: String = json_get_string(body, "path")
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
let p: String = if str_eq(p_raw, "") { dir + "/snapshot.json" } else { p_raw }
|
||||
// (2026-08-10 self-review) engram_save returns 0 on an empty path and the
|
||||
@@ -639,7 +686,6 @@ fn route_drift(method: String, path: String, body: String) -> String {
|
||||
|
||||
fn route_load(method: String, path: String, body: String) -> String {
|
||||
let p_raw: String = json_get_string(body, "path")
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
let p: String = if str_eq(p_raw, "") { dir + "/snapshot.json" } else { p_raw }
|
||||
// (2026-08-10 self-review) This was a stub response over the single most
|
||||
@@ -710,7 +756,6 @@ fn route_embed_backfill(method: String, path: String, body: String) -> String {
|
||||
// (it skips nodes already present by ID). Auth-exempt: same-host internal call.
|
||||
// (2026-06-27 self-review: added this route to fix silent 10-min sync failures)
|
||||
fn route_sync(method: String, path: String, body: String) -> String {
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
// 2026-07-21 self-review: export to a scratch path, never the canonical
|
||||
// snapshot.json — read routes must not be able to clobber the good snapshot.
|
||||
@@ -786,8 +831,12 @@ fn route_reseed_nodes(method: String, path: String, body: String) -> String {
|
||||
if str_eq(p, "") { return err_json("path is required") }
|
||||
if str_eq(fs_read(p), "") { return err_json("file missing or empty") }
|
||||
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = if str_eq(dir_raw, "") { "/tmp/engram" } else { dir_raw }
|
||||
// (2026-08-15) This site carried its own "/tmp/engram" fallback, which
|
||||
// DISAGREED with engram_resolve_data_dir() ($HOME/.neuron/engram, fail-loud).
|
||||
// The consumer is the pre-destructive backup below, so with ENGRAM_DATA_DIR
|
||||
// unset the safety copy taken before a reseed landed in an ephemeral /tmp
|
||||
// while the store it was protecting lived elsewhere. One owner, one answer.
|
||||
let dir: String = engram_resolve_data_dir()
|
||||
let backup: String = dir + "/.reseed-backup.json"
|
||||
|
||||
let replace_raw: String = json_get_raw(body, "replace")
|
||||
@@ -879,8 +928,7 @@ fn route_emit_ise(method: String, path: String, body: String) -> String {
|
||||
sal, imp, conf,
|
||||
"Episodic", "[\"internal-state\",\"InternalStateEvent\"]"
|
||||
)
|
||||
let ret_raw: String = env("ENGRAM_ISE_RETENTION_MS")
|
||||
let ret_ms: Int = if str_eq(ret_raw, "") { 172800000 } else { str_to_int(ret_raw) }
|
||||
let ret_ms: Int = str_to_int(config("ENGRAM_ISE_RETENTION_MS"))
|
||||
let pruned: Int = engram_prune_telemetry(ret_ms)
|
||||
"{\"ok\":true,\"id\":\"" + id + "\",\"pruned\":" + int_to_str(pruned) + "}"
|
||||
}
|
||||
@@ -1129,14 +1177,12 @@ fn route_correspondence_beat(method: String, path: String, body: String) -> Stri
|
||||
// turns native thinking ON: the response carries reasoning_content (the thinking)
|
||||
// alongside content (the answer).
|
||||
|
||||
fn guide_env_or(key: String, dflt: String) -> String {
|
||||
let v: String = env(key)
|
||||
if str_eq(v, "") { return dflt }
|
||||
return v
|
||||
}
|
||||
// (2026-08-15) guide_env_or(key, dflt) lived here. Its whole job was supplying a
|
||||
// per-call-site default, which is now the program block's job — every GUIDE_* knob
|
||||
// is declared once at the top of this file and read straight through config().
|
||||
|
||||
fn guide_enabled() -> Bool {
|
||||
let v: String = env("GUIDE_ENABLE")
|
||||
let v: String = config("GUIDE_ENABLE")
|
||||
if str_eq(v, "1") { return true }
|
||||
if str_eq(v, "on") { return true }
|
||||
if str_eq(v, "true") { return true }
|
||||
@@ -1181,15 +1227,15 @@ fn guide_probe_metal() -> Bool {
|
||||
|
||||
// ── 2. Tier selection (config-driven thresholds, spec-autoselected) ────────────
|
||||
fn guide_threshold_4b() -> Int {
|
||||
return str_to_int(guide_env_or("GUIDE_RAM_GB_4B", "16"))
|
||||
return str_to_int(config("GUIDE_RAM_GB_4B"))
|
||||
}
|
||||
fn guide_threshold_1p7b() -> Int {
|
||||
return str_to_int(guide_env_or("GUIDE_RAM_GB_1P7B", "8"))
|
||||
return str_to_int(config("GUIDE_RAM_GB_1P7B"))
|
||||
}
|
||||
|
||||
// GUIDE_TIER_FORCE overrides the spec autoselect (used to prove cheaply on 0.6b).
|
||||
fn guide_select_tier(ram_gb: Int) -> String {
|
||||
let forced: String = env("GUIDE_TIER_FORCE")
|
||||
let forced: String = config("GUIDE_TIER_FORCE")
|
||||
if !str_eq(forced, "") { return forced }
|
||||
if ram_gb >= guide_threshold_4b() { return "4b" }
|
||||
if ram_gb >= guide_threshold_1p7b() { return "1.7b" }
|
||||
@@ -1209,8 +1255,10 @@ fn guide_file(tier: String) -> String {
|
||||
}
|
||||
|
||||
fn guide_cache_dir() -> String {
|
||||
let c: String = env("GUIDE_CACHE_DIR")
|
||||
let c: String = config("GUIDE_CACHE_DIR")
|
||||
if !str_eq(c, "") { return c }
|
||||
// HOME stays a raw env() read: it is the ambient environment, not a knob of
|
||||
// this program, and it is deliberately absent from the program block.
|
||||
let home: String = env("HOME")
|
||||
if !str_eq(home, "") { return home + "/.neuron/guide/models" }
|
||||
return engram_resolve_data_dir() + "/guide-models"
|
||||
@@ -1251,9 +1299,9 @@ fn guide_fetch(tier: String) -> Bool {
|
||||
}
|
||||
|
||||
// ── 4/5. Backend abstraction + BIND as an engageable interlocutor ──────────────
|
||||
fn guide_backend() -> String { return guide_env_or("GUIDE_BACKEND", "llama-server") }
|
||||
fn guide_host() -> String { return guide_env_or("GUIDE_HOST", "127.0.0.1") }
|
||||
fn guide_port() -> String { return guide_env_or("GUIDE_PORT", "8771") }
|
||||
fn guide_backend() -> String { return config("GUIDE_BACKEND") }
|
||||
fn guide_host() -> String { return config("GUIDE_HOST") }
|
||||
fn guide_port() -> String { return config("GUIDE_PORT") }
|
||||
fn guide_base_url() -> String { return "http://" + guide_host() + ":" + guide_port() }
|
||||
|
||||
// guide_healthy — is the guide present and answering? llama-server's /health
|
||||
@@ -1271,9 +1319,9 @@ fn guide_healthy() -> Bool {
|
||||
fn guide_load(tier: String) -> Bool {
|
||||
if guide_healthy() { return true }
|
||||
let path: String = guide_model_path(tier)
|
||||
let bin: String = guide_env_or("GUIDE_LLAMA_SERVER_BIN", "llama-server")
|
||||
let ngl: String = guide_env_or("GUIDE_NGL", "99")
|
||||
let ctx: String = guide_env_or("GUIDE_CTX", "4096")
|
||||
let bin: String = config("GUIDE_LLAMA_SERVER_BIN")
|
||||
let ngl: String = config("GUIDE_NGL")
|
||||
let ctx: String = config("GUIDE_CTX")
|
||||
let logf: String = guide_cache_dir() + "/llama-server." + guide_port() + ".log"
|
||||
let cmd: String = bin + " -m '" + path + "' --host " + guide_host() + " --port " + guide_port() + " -c " + ctx + " -ngl " + ngl + " --jinja >> '" + logf + "' 2>&1"
|
||||
let pid: String = exec_bg(cmd)
|
||||
@@ -1669,7 +1717,7 @@ fn route_supersede(method: String, path: String, body: String) -> String {
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn check_auth_ok(method: String, body: String) -> Bool {
|
||||
let key: String = env("ENGRAM_API_KEY")
|
||||
let key: String = config("ENGRAM_API_KEY")
|
||||
if str_eq(key, "") { return true }
|
||||
// Read-only methods don't require auth. Until http_serve surfaces
|
||||
// request headers we can't accept a Bearer token cleanly; mutating
|
||||
@@ -1932,8 +1980,7 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
|
||||
// ── Entry ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let bind_raw: String = env("ENGRAM_BIND")
|
||||
let bind_str: String = if str_eq(bind_raw, "") { ":8742" } else { bind_raw }
|
||||
let bind_str: String = config("ENGRAM_BIND")
|
||||
let port: Int = parse_port(bind_str)
|
||||
|
||||
// On startup, try to load any existing snapshot (best effort).
|
||||
|
||||
@@ -73,6 +73,17 @@ When you add a C builtin (verbatim-emit recipe — the El name is emitted as the
|
||||
2. Add a `__`-prefixed thin wrapper in `el_seed.c` and declare it in `el_seed.h`.
|
||||
3. Add the name to `builtin_arity` in `el-compiler/src/codegen.el` — add **both** the plain and `__`-prefixed spellings.
|
||||
4. Rebuild the elc binary (see below) and confirm the self-host fixpoint is byte-identical.
|
||||
5. **Prove it with a NEGATIVE CONTROL.** Show the test FAILING on a build without your change, then passing with it. A test that has never been seen to fail has proven nothing.
|
||||
|
||||
> **Step 5 is not optional, and step 4 does not cover it.** The fixpoint proves the *compiler reproduces itself*. It says nothing whatsoever about whether your builtin works. A recipe ending at "byte-identical" reads as complete while having verified nothing about the thing just added — which is why this file, until 2026-08-16, produced builtins with no tests at all.
|
||||
>
|
||||
> Measured cost of the omission (2026-08-16): `engram_node_set_emb`, `engram_curiosity_json` and `dream_set_handler` were all added in one session with zero tests. Separately, a UTF-8 fix was written, tested, and **the test passed on the unpatched build too** — the defect was elsewhere entirely, and only building the pre-fix binary exposed it. Without a negative control that fix would have merged as verified.
|
||||
>
|
||||
> Two shapes that pass while proving nothing, both hit the same day:
|
||||
> - A test that never exercises your change (the route supplied a default that bypassed the code under test).
|
||||
> - An induction that loses a race. `curl --max-time` on a large response left *both* builds alive; only `SO_LINGER 0` — a genuine RST, so the peer is provably gone — reproduced the failure. Six of ten attempts is not a control.
|
||||
>
|
||||
> Before every probe, confirm **your** process bound the port (`lsof -nP -iTCP:<port>`, match the PID). A stale instance answering on the port has silently produced false results here more than once, and `pkill -f` does not reliably match an argv like `./engram`.
|
||||
|
||||
Worked example: the `engram_assert_json` (op_assert seam) and `engram_node_full_in`/`engram_connect_in` (purview write-side) primitives added 2026-08-15 follow exactly this recipe.
|
||||
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -3265,6 +3265,7 @@ fn is_top_level_decl(stmt: Map<String, Any>) -> Bool {
|
||||
if kind == "EnumDef" { return true }
|
||||
if kind == "Import" { return true }
|
||||
if kind == "CgiBlock" { return true }
|
||||
if kind == "ProgramBlock" { return true }
|
||||
if kind == "ExternFn" { return true }
|
||||
false
|
||||
}
|
||||
@@ -3277,6 +3278,55 @@ fn cgi_arg(value: String, has_value: Bool) -> String {
|
||||
return "EL_NULL"
|
||||
}
|
||||
|
||||
// -- Program block: cross-cutting concerns injected at the process boundary ----
|
||||
//
|
||||
// emit_program_init — emit the `static void __el_program_init(void)` that
|
||||
// carries a program's declared cross-cutting concerns. Called from main()
|
||||
// BEFORE any user statement runs, so the guarantees hold for the whole process
|
||||
// rather than depending on each call site remembering to ask for them.
|
||||
//
|
||||
// This is emitted at the point the `program` block is encountered, not buffered
|
||||
// until main(). The streaming backend emits in source order and cannot hold a
|
||||
// declaration's entry list alive until main(); emitting a named function here
|
||||
// and calling it from main() means only a single bool has to survive.
|
||||
//
|
||||
// Order matters and is deliberate:
|
||||
// 1. singleton FIRST — if another instance already holds the lock, refuse and
|
||||
// exit before touching configuration, ports, or any data directory.
|
||||
// 2. config declarations — resolve env-or-default, one declaration per entry.
|
||||
// 3. validate LAST — report EVERY missing/ill-typed entry at once, then exit.
|
||||
fn el_bool_arg(b: Bool) -> String {
|
||||
if b { return "EL_INT(1)" }
|
||||
return "EL_INT(0)"
|
||||
}
|
||||
|
||||
fn emit_program_init(stmt: Map<String, Any>) -> Void {
|
||||
let pname: String = stmt["name"]
|
||||
emit_line("static void __el_program_init(void) {")
|
||||
let has_singleton: Bool = stmt["has_singleton"]
|
||||
if has_singleton {
|
||||
let sid: String = stmt["singleton"]
|
||||
emit_line(" el_singleton_acquire(EL_STR(" + c_str_lit(sid) + "));")
|
||||
}
|
||||
let entries = stmt["entries"]
|
||||
let n: Int = native_list_len(entries)
|
||||
let i = 0
|
||||
while i < n {
|
||||
let e = native_list_get(entries, i)
|
||||
let ename: String = e["name"]
|
||||
let etype: String = e["etype"]
|
||||
let edefault: String = e["default"]
|
||||
let has_default: Bool = e["has_default"]
|
||||
let erequired: Bool = e["required"]
|
||||
let arg_def: String = cgi_arg(edefault, has_default)
|
||||
emit_line(" el_config_declare(EL_STR(" + c_str_lit(ename) + "), EL_STR(" + c_str_lit(etype) + "), " + arg_def + ", " + el_bool_arg(has_default) + ", " + el_bool_arg(erequired) + ");")
|
||||
let i = i + 1
|
||||
}
|
||||
emit_line(" el_config_validate(EL_STR(" + c_str_lit(pname) + "));")
|
||||
emit_line("}")
|
||||
emit_blank()
|
||||
}
|
||||
|
||||
// -- VBD role enforcement ------------------------------------------------------
|
||||
//
|
||||
// Scan a function body for direct calls to DHARMA-restricted builtins
|
||||
@@ -3599,6 +3649,20 @@ fn codegen(stmts: [Map<String, Any>], source: String) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Program block: emit the cross-cutting init function before the user's
|
||||
// functions so main() can call it (see emit_program_init).
|
||||
let prog_have: Bool = false
|
||||
let i = 0
|
||||
while i < n {
|
||||
let stmt = native_list_get(stmts, i)
|
||||
let sk4: String = stmt["stmt"]
|
||||
if str_eq(sk4, "ProgramBlock") {
|
||||
emit_program_init(stmt)
|
||||
let prog_have = true
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
|
||||
// Function definitions
|
||||
let i = 0
|
||||
while i < n {
|
||||
@@ -3617,6 +3681,9 @@ fn codegen(stmts: [Map<String, Any>], source: String) -> String {
|
||||
// with the C-side parameters when fn main()'s body is folded in below.
|
||||
emit_line("int main(int _argc, char** _argv) {")
|
||||
emit_line(" el_runtime_init_args(_argc, _argv);")
|
||||
if prog_have {
|
||||
emit_line(" __el_program_init();")
|
||||
}
|
||||
if cgi_count >= 1 {
|
||||
let cname: String = cgi_block["name"]
|
||||
let cdid: String = cgi_block["dharma_id"]
|
||||
@@ -4210,6 +4277,7 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
|
||||
// Fix: copy the values out BEFORE the release (strings, so no dangling reference)
|
||||
// and emit from these. No search, so the failure mode is removed rather than moved.
|
||||
let cgi_have: Bool = false
|
||||
let prog_have: Bool = false
|
||||
let cgi_name_v: String = ""
|
||||
let cgi_did_v: String = ""
|
||||
let cgi_prin_v: String = ""
|
||||
@@ -4331,6 +4399,14 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
|
||||
// These are no-ops in codegen (forward decls already emitted)
|
||||
// — except a CgiBlock, whose declared identity must survive
|
||||
// this release to be emitted as a compiled constant.
|
||||
// A ProgramBlock's cross-cutting declarations are
|
||||
// emitted HERE, as a named init function, because the
|
||||
// streaming backend cannot hold the entry list alive
|
||||
// until main(). Only the bool survives.
|
||||
if str_eq(sk, "ProgramBlock") {
|
||||
emit_program_init(stmt)
|
||||
let prog_have = true
|
||||
}
|
||||
if str_eq(sk, "CgiBlock") {
|
||||
let cgi_have = true
|
||||
let cgi_name_v = stmt["name"]
|
||||
@@ -4477,6 +4553,13 @@ fn codegen_streaming(tokens: [Any], sigs: [Map<String, Any>], source: String) ->
|
||||
let kind2: String = state_get("__program_kind")
|
||||
emit_line("int main(int _argc, char** _argv) {")
|
||||
emit_line(" el_runtime_init_args(_argc, _argv);")
|
||||
// Cross-cutting concerns declared by a `program` block run BEFORE anything
|
||||
// else — a singleton violation must refuse the start before this process
|
||||
// touches a port or a data directory, and configuration must be validated
|
||||
// before the first read of it rather than at each read site.
|
||||
if prog_have {
|
||||
emit_line(" __el_program_init();")
|
||||
}
|
||||
|
||||
// cgi init if needed
|
||||
let ns2: Int = native_list_len(sigs)
|
||||
|
||||
@@ -184,6 +184,7 @@ fn keyword_kind(word: String) -> String {
|
||||
if word == "false" { return "Bool" }
|
||||
if word == "cgi" { return "Cgi" }
|
||||
if word == "service" { return "Service" }
|
||||
if word == "program" { return "Program" }
|
||||
if word == "manager" { return "Manager" }
|
||||
if word == "engine" { return "Engine" }
|
||||
if word == "accessor" { return "Accessor" }
|
||||
|
||||
@@ -1967,6 +1967,113 @@ fn parse_stmt(tokens: [Any], pos: Int) -> Map<String, Any> {
|
||||
}, p)
|
||||
}
|
||||
|
||||
// program block: program "name" { singleton: "id", env NAME: Type = "default", ... }
|
||||
//
|
||||
// The program block is El's declaration surface for CROSS-CUTTING CONCERNS —
|
||||
// properties of the whole process rather than of any one function, which
|
||||
// otherwise degrade into "remember to call this at every site" conventions.
|
||||
//
|
||||
// singleton: "id" — process identity. The runtime takes an exclusive
|
||||
// lock at startup; a SECOND start is refused, loudly,
|
||||
// instead of two processes sharing one data dir.
|
||||
// env NAME: T = "d" — one configuration entry. Its type and its default
|
||||
// are declared ONCE, here, and resolved+validated
|
||||
// before main() body runs.
|
||||
// env NAME: T required
|
||||
// — no default; the program refuses to start unless the
|
||||
// variable is set.
|
||||
//
|
||||
// Both compile into calls injected at the head of main() — the same boundary
|
||||
// seam `cgi` already uses (codegen.el emit_program_init). No call site in the
|
||||
// program body has to remember anything, which is the whole point.
|
||||
if k == "Program" {
|
||||
let p = pos + 1
|
||||
let name = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
let p = expect(tokens, p, "LBrace")
|
||||
let singleton = ""
|
||||
let has_singleton = false
|
||||
let entries = native_list_empty()
|
||||
// Entry-scratch declared at loop-body level (not inside the branch) so
|
||||
// that inner `let` forms compile to assignment rather than a C-scoped
|
||||
// redeclaration — the same idiom the service block above relies on.
|
||||
let ename = ""
|
||||
let etype = ""
|
||||
let edefault = ""
|
||||
let has_default = false
|
||||
let erequired = false
|
||||
let fname = ""
|
||||
let fval = ""
|
||||
let running = true
|
||||
while running {
|
||||
let k2 = tok_kind(tokens, p)
|
||||
if k2 == "RBrace" {
|
||||
let running = false
|
||||
} else {
|
||||
if k2 == "Eof" {
|
||||
let running = false
|
||||
} else {
|
||||
let fname = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
if str_eq(fname, "env") {
|
||||
// env NAME: Type [= "default"] [required]
|
||||
let ename = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
let p = expect(tokens, p, "Colon")
|
||||
let etype = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
let edefault = ""
|
||||
let has_default = false
|
||||
let erequired = false
|
||||
let k3 = tok_kind(tokens, p)
|
||||
if str_eq(k3, "Eq") {
|
||||
let p = p + 1
|
||||
let edefault = tok_value(tokens, p)
|
||||
let has_default = true
|
||||
let p = p + 1
|
||||
}
|
||||
let k4 = tok_kind(tokens, p)
|
||||
if str_eq(k4, "Ident") {
|
||||
let w = tok_value(tokens, p)
|
||||
if str_eq(w, "required") {
|
||||
let erequired = true
|
||||
let p = p + 1
|
||||
}
|
||||
}
|
||||
let entries = native_list_append(entries, {
|
||||
"name": ename,
|
||||
"etype": etype,
|
||||
"default": edefault,
|
||||
"has_default": has_default,
|
||||
"required": erequired
|
||||
})
|
||||
} else {
|
||||
// scalar field: `name: "value"`
|
||||
let p = expect(tokens, p, "Colon")
|
||||
let fval = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
if str_eq(fname, "singleton") {
|
||||
let singleton = fval
|
||||
let has_singleton = true
|
||||
}
|
||||
}
|
||||
let k5 = tok_kind(tokens, p)
|
||||
if k5 == "Comma" {
|
||||
let p = p + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let p = expect(tokens, p, "RBrace")
|
||||
return make_result({
|
||||
"stmt": "ProgramBlock",
|
||||
"name": name,
|
||||
"singleton": singleton,
|
||||
"has_singleton": has_singleton,
|
||||
"entries": entries
|
||||
}, p)
|
||||
}
|
||||
|
||||
// assert <cond_expr> [ , <msg_expr> ]
|
||||
// The message is optional — if the next token after the condition is not a
|
||||
// Comma, emit an empty string placeholder so the test still works.
|
||||
@@ -2419,6 +2526,7 @@ fn scan_params_c(tokens: [Any], pos: Int) -> Map<String, Any> {
|
||||
// toplevel_let: { "kind": "toplevel_let", "name": String, "ltype": String }
|
||||
// cgi_block: { "kind": "cgi_block", "name": String }
|
||||
// service_block: { "kind": "service_block", "name": String }
|
||||
// program_block: { "kind": "program_block", "name": String }
|
||||
//
|
||||
// Import/TypeDef/EnumDef nodes are skipped (codegen treats them as no-ops).
|
||||
//
|
||||
@@ -2546,13 +2654,28 @@ fn scan_fn_sigs(tokens: [Any]) -> [Map<String, Any>] {
|
||||
"name": name
|
||||
})
|
||||
let pos = p
|
||||
} else {
|
||||
// --- program block ---
|
||||
if str_eq(k, "Program") {
|
||||
let p: Int = pos + 1
|
||||
let name: String = tok_value(tokens, p)
|
||||
let p = p + 1
|
||||
let k2: String = tok_kind(tokens, p)
|
||||
if str_eq(k2, "LBrace") {
|
||||
let p = skip_to_rbrace(tokens, p)
|
||||
}
|
||||
let sigs = native_list_append(sigs, {
|
||||
"kind": "program_block",
|
||||
"name": name
|
||||
})
|
||||
let pos = p
|
||||
} else {
|
||||
// Import, Type, Enum, From, or any other token.
|
||||
// Skip ahead to the next statement boundary.
|
||||
let p: Int = pos + 1
|
||||
let p = skip_expr_to_stmt_boundary(tokens, p)
|
||||
let pos = p
|
||||
}}}}}
|
||||
}}}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+448
-33
@@ -40,9 +40,11 @@
|
||||
#include <sys/stat.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <signal.h> /* SIGPIPE disposition: a hung-up client must not kill us */
|
||||
#include <dlfcn.h> /* dlsym for http_set_handler fallback */
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/file.h> /* flock — process-identity singleton (program block) */
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
@@ -1334,10 +1336,63 @@ static const char* http_reason_phrase(int status) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Best-effort send with retry on partial writes. */
|
||||
/* A DISCONNECTING CLIENT MUST NOT KILL THE SERVER (2026-08-16).
|
||||
*
|
||||
* 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
|
||||
* hung up mid-response — a curl that hit its timeout, a browser tab closed
|
||||
* during a large read, a proxy giving up — took the whole engram down with it.
|
||||
*
|
||||
* Measured on the live instance: 18 boots in the log, and `launchctl list`
|
||||
* reporting the previous exit for ai.neuron.engram as -13, i.e. killed by
|
||||
* signal 13 = SIGPIPE. Reproduced by the cause: pulling /api/nodes/list (26 MB)
|
||||
* with a client-side timeout. launchd's KeepAlive then restarts it, so the
|
||||
* failure looks like a mysterious restart rather than a crash, and the graph
|
||||
* silently reloads under whatever was mid-flight.
|
||||
*
|
||||
* This is an exemption in the §8 sense: the write never checked whether the
|
||||
* peer was still there, and the consequence of not checking was fatal rather
|
||||
* than merely wrong.
|
||||
*
|
||||
* Two layers, because neither alone is portable:
|
||||
* - SO_NOSIGPIPE per socket (Darwin/BSD) and MSG_NOSIGNAL per send (Linux),
|
||||
* so the signal is never raised for socket writes in the first place.
|
||||
* - A process-wide SIG_IGN as the backstop for platforms/paths with neither,
|
||||
* installed once and idempotent. With the signal ignored, send() returns
|
||||
* -1/EPIPE and the existing error path closes the connection. */
|
||||
#ifndef MSG_NOSIGNAL
|
||||
#define MSG_NOSIGNAL 0
|
||||
#endif
|
||||
|
||||
static void el_ignore_sigpipe_once(void) {
|
||||
static int done = 0;
|
||||
if (done) return;
|
||||
done = 1;
|
||||
#ifndef _WIN32
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Per-socket suppression where the platform offers it. Best-effort: a failure
|
||||
* here is not fatal because el_ignore_sigpipe_once() already covers the case. */
|
||||
static void el_sock_nosigpipe(int fd) {
|
||||
#if defined(SO_NOSIGPIPE)
|
||||
int on = 1;
|
||||
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on));
|
||||
#else
|
||||
(void)fd;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Best-effort send with retry on partial writes. EPIPE/ECONNRESET are a client
|
||||
* that left, not a server fault: return -1 so the caller closes the connection,
|
||||
* and never let it reach the process as a signal. */
|
||||
static int http_send_all(int fd, const char* p, size_t left) {
|
||||
el_ignore_sigpipe_once();
|
||||
while (left > 0) {
|
||||
ssize_t w = send(fd, p, left, 0);
|
||||
ssize_t w = send(fd, p, left, MSG_NOSIGNAL);
|
||||
if (w < 0 && errno == EINTR) continue;
|
||||
if (w <= 0) return -1;
|
||||
p += w; left -= (size_t)w;
|
||||
}
|
||||
@@ -1787,6 +1842,7 @@ void http_serve(el_val_t port, el_val_t handler) {
|
||||
pthread_mutex_unlock(&_http_conn_mu);
|
||||
HttpWorkerArg* arg = malloc(sizeof(HttpWorkerArg));
|
||||
if (!arg) { el_closesocket(cfd); continue; }
|
||||
el_sock_nosigpipe(cfd);
|
||||
arg->fd = cfd;
|
||||
pthread_t tid;
|
||||
if (pthread_create(&tid, NULL, http_worker, arg) != 0) {
|
||||
@@ -1833,6 +1889,7 @@ static void* _http_serve_async_loop(void* raw) {
|
||||
pthread_mutex_unlock(&_http_conn_mu);
|
||||
HttpWorkerArg* arg = malloc(sizeof(HttpWorkerArg));
|
||||
if (!arg) { close(cfd); continue; }
|
||||
el_sock_nosigpipe(cfd);
|
||||
arg->fd = cfd;
|
||||
pthread_t tid;
|
||||
if (pthread_create(&tid, NULL, http_worker, arg) != 0) {
|
||||
@@ -2133,6 +2190,7 @@ void http_serve_v2(el_val_t port, el_val_t handler) {
|
||||
pthread_mutex_unlock(&_http_conn_mu);
|
||||
HttpWorkerArg* arg = malloc(sizeof(HttpWorkerArg));
|
||||
if (!arg) { el_closesocket(cfd); continue; }
|
||||
el_sock_nosigpipe(cfd);
|
||||
arg->fd = cfd;
|
||||
pthread_t tid;
|
||||
if (pthread_create(&tid, NULL, http_worker_v2, arg) != 0) {
|
||||
@@ -3478,28 +3536,74 @@ static void jb_puts(JsonBuf* b, const char* s) {
|
||||
b->buf[b->len] = '\0';
|
||||
}
|
||||
|
||||
/* UTF-8 VALIDITY IS THE EMITTER'S CONTRACT (2026-08-16 self-review).
|
||||
*
|
||||
* This copied every byte >= 0x20 through verbatim, so a malformed sequence
|
||||
* anywhere in the store became malformed output. Measured against the live
|
||||
* graph: three nodes carry labels truncated to exactly 80 bytes ending in a
|
||||
* lone 0xE2 — the first byte of an em-dash, cut mid-sequence by some producer
|
||||
* that is NOT this runtime (no 80-byte truncation exists here; the content
|
||||
* itself is 2572 and 2746 bytes). Those three nodes made the ENTIRE 26 MB
|
||||
* /api/nodes/list response undecodable, so a strict parser could not read the
|
||||
* graph at all.
|
||||
*
|
||||
* Fixing only the writer would not have helped: the store already contains the
|
||||
* damage, and it accepts data from importers, other producers and older
|
||||
* binaries. A serializer that promises JSON owes valid UTF-8 regardless of what
|
||||
* it is handed — so validate here, at the boundary that makes the promise.
|
||||
* Invalid bytes become U+FFFD rather than being dropped, so damage stays
|
||||
* visible in the output instead of being silently papered over.
|
||||
*
|
||||
* Well-formed input is byte-identical to before: valid sequences are copied
|
||||
* verbatim, and only structurally invalid ones (bad lead byte, missing or bad
|
||||
* continuation, overlong encoding, UTF-16 surrogate, or > U+10FFFF) are
|
||||
* replaced. */
|
||||
static void jb_emit_escaped(JsonBuf* b, const char* s) {
|
||||
jb_putc(b, '"');
|
||||
for (; *s; s++) {
|
||||
unsigned char c = (unsigned char)*s;
|
||||
const unsigned char* p = (const unsigned char*)s;
|
||||
while (*p) {
|
||||
unsigned char c = *p;
|
||||
switch (c) {
|
||||
case '"': jb_puts(b, "\\\""); break;
|
||||
case '\\': jb_puts(b, "\\\\"); break;
|
||||
case '\b': jb_puts(b, "\\b"); break;
|
||||
case '\f': jb_puts(b, "\\f"); break;
|
||||
case '\n': jb_puts(b, "\\n"); break;
|
||||
case '\r': jb_puts(b, "\\r"); break;
|
||||
case '\t': jb_puts(b, "\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
char tmp[8];
|
||||
snprintf(tmp, sizeof(tmp), "\\u%04x", c);
|
||||
jb_puts(b, tmp);
|
||||
} else {
|
||||
jb_putc(b, (char)c);
|
||||
}
|
||||
break;
|
||||
case '"': jb_puts(b, "\\\""); p++; continue;
|
||||
case '\\': jb_puts(b, "\\\\"); p++; continue;
|
||||
case '\b': jb_puts(b, "\\b"); p++; continue;
|
||||
case '\f': jb_puts(b, "\\f"); p++; continue;
|
||||
case '\n': jb_puts(b, "\\n"); p++; continue;
|
||||
case '\r': jb_puts(b, "\\r"); p++; continue;
|
||||
case '\t': jb_puts(b, "\\t"); p++; continue;
|
||||
default: break;
|
||||
}
|
||||
if (c < 0x20) {
|
||||
char tmp[8];
|
||||
snprintf(tmp, sizeof(tmp), "\\u%04x", c);
|
||||
jb_puts(b, tmp);
|
||||
p++;
|
||||
continue;
|
||||
}
|
||||
if (c < 0x80) { jb_putc(b, (char)c); p++; continue; }
|
||||
|
||||
/* Multi-byte: validate the whole sequence before emitting any of it. */
|
||||
int len; unsigned int cp;
|
||||
if ((c & 0xE0) == 0xC0) { len = 2; cp = c & 0x1Fu; }
|
||||
else if ((c & 0xF0) == 0xE0) { len = 3; cp = c & 0x0Fu; }
|
||||
else if ((c & 0xF8) == 0xF0) { len = 4; cp = c & 0x07u; }
|
||||
else { jb_puts(b, "\\ufffd"); p++; continue; }
|
||||
|
||||
int ok = 1;
|
||||
for (int i = 1; i < len; i++) {
|
||||
if ((p[i] & 0xC0) != 0x80) { ok = 0; break; } /* also catches NUL */
|
||||
cp = (cp << 6) | (unsigned int)(p[i] & 0x3F);
|
||||
}
|
||||
if (ok) {
|
||||
if (len == 2 && cp < 0x80) ok = 0; /* overlong */
|
||||
else if (len == 3 && cp < 0x800) ok = 0; /* overlong */
|
||||
else if (len == 4 && cp < 0x10000) ok = 0; /* overlong */
|
||||
else if (cp >= 0xD800 && cp <= 0xDFFF) ok = 0; /* UTF-16 surrogate */
|
||||
else if (cp > 0x10FFFF) ok = 0; /* out of range */
|
||||
}
|
||||
if (!ok) { jb_puts(b, "\\ufffd"); p++; continue; }
|
||||
for (int i = 0; i < len; i++) jb_putc(b, (char)p[i]);
|
||||
p += len;
|
||||
}
|
||||
jb_putc(b, '"');
|
||||
}
|
||||
@@ -5516,6 +5620,45 @@ el_val_t str_count(el_val_t sv, el_val_t subv) {
|
||||
return (el_val_t)count;
|
||||
}
|
||||
|
||||
/* el_utf8_safe_len — the largest byte length <= max_bytes that does NOT split a
|
||||
* UTF-8 codepoint.
|
||||
*
|
||||
* WHY (2026-08-16 self-review): engram_first_n_chars truncated with a plain
|
||||
* `if (l > n) l = n; memcpy(...)`, i.e. by BYTES despite its name. Any content
|
||||
* carrying a multi-byte character across the 60-byte boundary produced a label
|
||||
* ending in a half codepoint. That label is copied verbatim into every JSON
|
||||
* document containing the node, so a single such node makes the WHOLE response
|
||||
* invalid UTF-8 — /api/nodes/list failed to decode at byte 89261 against the
|
||||
* live store, which breaks any strict parser reading the graph.
|
||||
*
|
||||
* This lives beside str_count_chars rather than in the engram because the rest
|
||||
* of el's string layer is already codepoint-aware (str_count_chars counts
|
||||
* codepoints, str_reverse walks codepoint lengths). Byte-truncation was the
|
||||
* outlier, and the concern is a string concern. Bounded by BYTES, not
|
||||
* codepoints, so existing labels never grow — only stop splitting.
|
||||
*
|
||||
* A lead byte with no room for its full sequence is dropped entirely; a stray
|
||||
* continuation byte (already-invalid input) is passed through unchanged rather
|
||||
* than silently repaired, so this never manufactures data. */
|
||||
size_t el_utf8_safe_len(const char* s, size_t max_bytes) {
|
||||
if (!s) return 0;
|
||||
size_t len = strlen(s);
|
||||
if (len <= max_bytes) return len;
|
||||
size_t i = 0;
|
||||
while (i < max_bytes) {
|
||||
unsigned char c = (unsigned char)s[i];
|
||||
size_t cp_len;
|
||||
if ((c & 0x80) == 0x00) cp_len = 1;
|
||||
else if ((c & 0xE0) == 0xC0) cp_len = 2;
|
||||
else if ((c & 0xF0) == 0xE0) cp_len = 3;
|
||||
else if ((c & 0xF8) == 0xF0) cp_len = 4;
|
||||
else cp_len = 1; /* stray continuation: passthrough */
|
||||
if (i + cp_len > max_bytes) break; /* would split — stop before it */
|
||||
i += cp_len;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/* Codepoint count: walk bytes, count those NOT matching 10xxxxxx. */
|
||||
el_val_t str_count_chars(el_val_t sv) {
|
||||
const char* s = EL_CSTR(sv);
|
||||
@@ -8016,10 +8159,14 @@ static double engram_decode_score(el_val_t v) {
|
||||
return (double)n;
|
||||
}
|
||||
|
||||
/* Truncate to at most n BYTES without splitting a UTF-8 codepoint. The old
|
||||
* implementation was `if (l > n) l = n;` — a byte cut that could land inside a
|
||||
* multi-byte character and emit a half codepoint into the node's label, which
|
||||
* then propagated into every JSON document containing that node. See
|
||||
* el_utf8_safe_len for the measurement. */
|
||||
static char* engram_first_n_chars(const char* s, size_t n) {
|
||||
if (!s) return el_strdup("");
|
||||
size_t l = strlen(s);
|
||||
if (l > n) l = n;
|
||||
size_t l = el_utf8_safe_len(s, n);
|
||||
char* out = el_strbuf(l);
|
||||
memcpy(out, s, l);
|
||||
out[l] = '\0';
|
||||
@@ -14255,7 +14402,40 @@ static int eg_cog_is_keystone_seeds(const char* csv) {
|
||||
el_val_t engram_think_json(el_val_t seeds, el_val_t faculty) {
|
||||
GeoDescriptor* g = eg_geo_build_desc(EL_CSTR(seeds));
|
||||
if (!g) return eg_geo_err("geometry unavailable");
|
||||
CogStance st; cog_stance_init(&st, NULL, EL_CSTR(faculty), g->hub_id, NULL, g);
|
||||
/* RESUME THE LEARNED STANCE (2026-08-16 self-review). This built a NEUTRAL
|
||||
* stance every call — all axis_gain 1.0, bias_dir NULL, reliability 0.5 —
|
||||
* and never loaded the one the correspondence-beat had been persisting.
|
||||
*
|
||||
* That mattered because the faculty enters engram_think ONLY through the
|
||||
* stance: `gain = stance->axis_gain[k]` warps the per-axis extents, and
|
||||
* `stance->bias_dir` seeds the steering direction. cog_stance_init stores
|
||||
* the faculty NAME but nothing reads it. So with a neutral stance,
|
||||
* reason / abduce / induce / plan / analogize are the same function with
|
||||
* different labels — measured, byte-identical output across all five —
|
||||
* and `confidence` is pinned to the 0.5 uninformed prior, because
|
||||
* GeoGradient.confidence is just stance->reliability.
|
||||
*
|
||||
* The machinery already existed and only this call site ignored it:
|
||||
* engram_correspondence_beat_json resumes via cog_stance_from_node and
|
||||
* persists via cog_stance_to_node under the id "stance-<faculty>-<hub>".
|
||||
* Every beat's calibration was being written and then thrown away on the
|
||||
* next read. Same defect as the NULL anchor directly above: a neutral
|
||||
* argument collapsing a capability to a constant.
|
||||
*
|
||||
* Resume the same id the beat writes, so learning compounds across beats
|
||||
* and cold boot. Fall back to neutral only when no stance exists yet —
|
||||
* which is a genuine uninformed prior, not a discarded informed one. */
|
||||
char sid[256];
|
||||
snprintf(sid, sizeof sid, "stance-%s-%s",
|
||||
EL_CSTR(faculty) ? EL_CSTR(faculty) : "reason",
|
||||
g->hub_id ? g->hub_id : "region");
|
||||
CogStance st; StoreNode prev; int resumed = 0;
|
||||
if (g_engram_store && store_get_node(g_engram_store, sid, &prev) == 1) {
|
||||
if (cog_stance_from_node(&prev, &st) == 0) resumed = 1;
|
||||
store_node_free(&prev);
|
||||
}
|
||||
if (!resumed) cog_stance_init(&st, sid, EL_CSTR(faculty), g->hub_id, NULL, g);
|
||||
else { free(st.id); st.id = strdup(sid); }
|
||||
GeoGradient grad;
|
||||
|
||||
/* ANCHOR THE READ (2026-08-16 self-review). This passed NULL, and NULL is
|
||||
@@ -14317,8 +14497,13 @@ el_val_t engram_think_json(el_val_t seeds, el_val_t faculty) {
|
||||
if (engram_think(g, anchor, &st, &grad) != 0) { free(anchor); cog_stance_free(&st); engram_geo_free(g); return eg_geo_err("think failed"); }
|
||||
free(anchor);
|
||||
JsonBuf b; jb_init(&b); char t[256];
|
||||
snprintf(t, sizeof t, "{\"faculty\":\"%s\",\"n_support\":%d,\"magnitude\":%.6g,\"spread\":%.6g,\"confidence\":%.6g,\"dim\":%d",
|
||||
EL_CSTR(faculty), grad.n_support, grad.magnitude, grad.spread, grad.confidence, grad.dim);
|
||||
/* stance_resumed distinguishes an INFORMED read from an uninformed one.
|
||||
* Without it, confidence 0.5 from a learned-but-unreliable stance and
|
||||
* confidence 0.5 from "no stance exists" are indistinguishable — the same
|
||||
* reporting gap that let the NULL anchor and the neutral stance hide. */
|
||||
snprintf(t, sizeof t, "{\"faculty\":\"%s\",\"n_support\":%d,\"magnitude\":%.6g,\"spread\":%.6g,\"confidence\":%.6g,\"stance_resumed\":%s,\"dim\":%d",
|
||||
EL_CSTR(faculty), grad.n_support, grad.magnitude, grad.spread, grad.confidence,
|
||||
resumed ? "true" : "false", grad.dim);
|
||||
jb_puts(&b, t);
|
||||
int emit = grad.dim < 8 ? grad.dim : 8;
|
||||
jb_puts(&b, ",\"direction\":"); eg_geo_emit_vec(&b, grad.direction, emit);
|
||||
@@ -14342,12 +14527,57 @@ el_val_t engram_ground_json(el_val_t claim, el_val_t evidence, el_val_t for_whom
|
||||
double grounding = (rc == 0) ? gr.grounding : 0.0;
|
||||
if (rc == 0) engram_verify_grounding_free(&gr);
|
||||
const char* fw = EL_CSTR(for_whom); if (fw && !*fw) fw = NULL;
|
||||
const char* cid = C->hub_id ? C->hub_id : EL_CSTR(claim);
|
||||
const char* eid = E->hub_id ? E->hub_id : EL_CSTR(evidence);
|
||||
int wr = cog_ground_edge(g_engram_store, cid, eid, grounding, fw);
|
||||
JsonBuf b; jb_init(&b); char t[256];
|
||||
snprintf(t, sizeof t, "{\"relation\":\"grounded-by\",\"claim\":\"%s\",\"evidence\":\"%s\",\"for_whom\":\"%s\",\"grounding\":%.6g,\"written\":%s}",
|
||||
cid, eid, fw ? fw : "-", grounding, wr == 0 ? "true" : "false");
|
||||
|
||||
/* GROUND THE NODE ASKED ABOUT, AND SAY WHAT WAS RESOLVED (2026-08-16
|
||||
* self-review). This wrote the grounded-by edge between the two REGION
|
||||
* HUBS and then echoed those hubs back in the "claim"/"evidence" fields
|
||||
* as though they were the caller's input. Three consequences, all measured
|
||||
* against the live store:
|
||||
*
|
||||
* 1. The edge landed on a node the caller never named. Asking to ground
|
||||
* 3b9ced5d against 6edf8c79 wrote an edge on 6edf8c79 -> d0406dfd,
|
||||
* because those were the hubs of the two regions.
|
||||
* 2. When both seeds resolve into the same region, the hubs coincide and
|
||||
* the call grounds a node against ITSELF, returning grounding = 1 —
|
||||
* a perfect score with no evidence behind it. Two independent agents
|
||||
* hit this and reported 0.885 / 0.909 self-groundings as confident.
|
||||
* 3. The echo concealed both, because the response looked exactly like 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 attaches to the requested ids, and the resolved hubs
|
||||
* are reported separately under claim_region / evidence_region. When the
|
||||
* two regions coincide, the grounding is degenerate by construction and is
|
||||
* reported as such rather than as a confident 1.0. */
|
||||
const char* cid = EL_CSTR(claim);
|
||||
const char* eid = EL_CSTR(evidence);
|
||||
const char* chub = C->hub_id ? C->hub_id : cid;
|
||||
const char* ehub = E->hub_id ? E->hub_id : eid;
|
||||
/* Degeneracy is broader than chub == ehub. Three circular shapes, each of
|
||||
* which yields a high score for structural reasons rather than evidential
|
||||
* ones, and all three were previously invisible:
|
||||
* same-region both seeds resolve to one region — grounding a thing
|
||||
* against itself.
|
||||
* claim-in-ev the claim's region hub IS the evidence node: the evidence
|
||||
* sits at the centre of the claim's own neighbourhood.
|
||||
* ev-in-claim the mirror case.
|
||||
* Measured: grounding 3b9ced5d against 6edf8c79 scored 0.98883 purely
|
||||
* because 6edf8c79 is the hub of 3b9ced5d's region. */
|
||||
const char* degenerate = NULL;
|
||||
if (chub && ehub && strcmp(chub, ehub) == 0) degenerate = "same-region";
|
||||
else if (chub && eid && strcmp(chub, eid) == 0) degenerate = "claim-region-is-evidence";
|
||||
else if (ehub && cid && strcmp(ehub, cid) == 0) degenerate = "evidence-region-is-claim";
|
||||
if (degenerate) grounding = 0.0; /* circular support is not support */
|
||||
|
||||
/* Do not write an edge for a grounding that is degenerate by construction. */
|
||||
int wr = degenerate ? -1 : cog_ground_edge(g_engram_store, cid, eid, grounding, fw);
|
||||
JsonBuf b; jb_init(&b); char t[512];
|
||||
snprintf(t, sizeof t, "{\"relation\":\"grounded-by\",\"claim\":\"%s\",\"evidence\":\"%s\","
|
||||
"\"claim_region\":\"%s\",\"evidence_region\":\"%s\",\"degenerate\":%s%s%s,"
|
||||
"\"for_whom\":\"%s\",\"grounding\":%.6g,\"written\":%s}",
|
||||
cid ? cid : "", eid ? eid : "", chub ? chub : "", ehub ? ehub : "",
|
||||
degenerate ? "\"" : "false", degenerate ? degenerate : "", degenerate ? "\"" : "",
|
||||
fw ? fw : "-", grounding, wr == 0 ? "true" : "false");
|
||||
jb_puts(&b, t);
|
||||
engram_geo_free(C); engram_geo_free(E);
|
||||
return el_wrap_str(b.buf);
|
||||
@@ -18653,11 +18883,196 @@ void log_warn(el_val_t msg_v) {
|
||||
fprintf(stderr, "[WARN] %s\n", msg ? msg : "");
|
||||
}
|
||||
|
||||
/* config — read a configuration value from the environment.
|
||||
* Returns "" if the variable is not set (same as __env_get). */
|
||||
/* ── Cross-cutting concerns: process identity and configuration ──────────────
|
||||
*
|
||||
* These back the `program` block (see lang/spec/language.md §18). Both concerns
|
||||
* were previously conventions — "check nothing is already running first",
|
||||
* "remember the right default at every read site" — and conventions is exactly
|
||||
* what they failed as. Here they are mechanisms, injected by the compiler at
|
||||
* the process boundary, so no call site has to remember anything.
|
||||
*/
|
||||
|
||||
/* -- Process identity ------------------------------------------------------- */
|
||||
|
||||
/* The lock fd is deliberately never closed. Holding it open for the process
|
||||
* lifetime is what makes the guarantee work: the kernel drops an flock when the
|
||||
* owning process dies, including on SIGKILL and on crash. That is why this is an
|
||||
* flock and not a bare pidfile — there is no stale-lock state to clean up, and
|
||||
* therefore no "delete the pidfile to get unstuck" ritual that would itself
|
||||
* become a convention. */
|
||||
static int el_singleton_fd = -1;
|
||||
static char el_singleton_path[1024];
|
||||
|
||||
static const char* el_singleton_dir(void) {
|
||||
const char* d = getenv("EL_SINGLETON_DIR");
|
||||
if (d && *d) return d;
|
||||
d = getenv("TMPDIR");
|
||||
if (d && *d) return d;
|
||||
return "/tmp";
|
||||
}
|
||||
|
||||
/* el_singleton_acquire — claim exclusive process identity, or refuse to start.
|
||||
* Compiler-injected as the FIRST statement of main() for any program whose
|
||||
* `program` block declares `singleton:`. */
|
||||
el_val_t el_singleton_acquire(el_val_t id_v) {
|
||||
const char* id = EL_CSTR(id_v);
|
||||
if (!id || !*id) return EL_NULL;
|
||||
|
||||
/* Sanitise the id into a filename. */
|
||||
char safe[256];
|
||||
size_t si = 0;
|
||||
for (const char* p = id; *p && si + 1 < sizeof(safe); p++) {
|
||||
char c = *p;
|
||||
int ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
|| (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.';
|
||||
safe[si++] = (char)(ok ? c : '-');
|
||||
}
|
||||
safe[si] = '\0';
|
||||
snprintf(el_singleton_path, sizeof(el_singleton_path),
|
||||
"%s/el-singleton-%s.lock", el_singleton_dir(), safe);
|
||||
|
||||
int fd = open(el_singleton_path, O_RDWR | O_CREAT, 0644);
|
||||
if (fd < 0) {
|
||||
fprintf(stderr, "[el] FATAL: singleton '%s': cannot open lock file %s: %s\n",
|
||||
id, el_singleton_path, strerror(errno));
|
||||
exit(1);
|
||||
}
|
||||
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
|
||||
/* Someone else holds it. Report WHO. A pid is actionable; "already
|
||||
* running" is not — and the observed failure was precisely a stale
|
||||
* process that `pkill -f` had silently failed to match, still answering
|
||||
* probes while a fresh build was believed to be under test. */
|
||||
char buf[64];
|
||||
buf[0] = '\0';
|
||||
ssize_t n = pread(fd, buf, sizeof(buf) - 1, 0);
|
||||
if (n > 0) buf[n] = '\0';
|
||||
long holder = strtol(buf, NULL, 10);
|
||||
fprintf(stderr, "[el] FATAL: another instance of '%s' is already running", id);
|
||||
if (holder > 0) fprintf(stderr, " (pid %ld)", holder);
|
||||
fprintf(stderr, ".\n"
|
||||
"[el] lock: %s\n"
|
||||
"[el] Refusing to start a second instance against the same\n"
|
||||
"[el] state. Stop the running one and VERIFY it is gone\n"
|
||||
"[el] (ps -p <pid>) before retrying.\n",
|
||||
el_singleton_path);
|
||||
close(fd);
|
||||
exit(1);
|
||||
}
|
||||
/* We own it. Record our pid so the next would-be starter can name us. */
|
||||
if (ftruncate(fd, 0) != 0) { /* best effort — the lock is the guarantee */ }
|
||||
char pidbuf[32];
|
||||
int pn = snprintf(pidbuf, sizeof(pidbuf), "%ld\n", (long)getpid());
|
||||
if (pn > 0) { ssize_t w = write(fd, pidbuf, (size_t)pn); (void)w; }
|
||||
el_singleton_fd = fd; /* never closed, by design */
|
||||
return EL_NULL;
|
||||
}
|
||||
|
||||
/* -- Configuration ---------------------------------------------------------- */
|
||||
|
||||
#define EL_CONFIG_MAX 128
|
||||
|
||||
typedef struct {
|
||||
char name[128];
|
||||
char type[16];
|
||||
char* value; /* resolved: env value, else default; NULL if unset */
|
||||
int has_default;
|
||||
int required;
|
||||
} ElConfigEntry;
|
||||
|
||||
static ElConfigEntry el_config_tab[EL_CONFIG_MAX];
|
||||
static int el_config_n = 0;
|
||||
static int el_config_has_schema = 0; /* did this program declare one at all? */
|
||||
|
||||
static int el_config_is_int(const char* s) {
|
||||
if (!s || !*s) return 0;
|
||||
if (*s == '-' || *s == '+') s++;
|
||||
if (!*s) return 0;
|
||||
for (; *s; s++) if (*s < '0' || *s > '9') return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* el_config_declare — record ONE configuration entry and resolve it now.
|
||||
* The default lives here, in the declaration, and nowhere else. */
|
||||
el_val_t el_config_declare(el_val_t name_v, el_val_t type_v, el_val_t def_v,
|
||||
el_val_t has_default_v, el_val_t required_v) {
|
||||
const char* name = EL_CSTR(name_v);
|
||||
if (!name || !*name) return EL_NULL;
|
||||
el_config_has_schema = 1;
|
||||
if (el_config_n >= EL_CONFIG_MAX) {
|
||||
fprintf(stderr, "[el] FATAL: more than %d config entries declared.\n", EL_CONFIG_MAX);
|
||||
exit(1);
|
||||
}
|
||||
const char* type = EL_CSTR(type_v);
|
||||
const char* def = (def_v == EL_NULL) ? NULL : EL_CSTR(def_v);
|
||||
ElConfigEntry* e = &el_config_tab[el_config_n++];
|
||||
snprintf(e->name, sizeof(e->name), "%s", name);
|
||||
snprintf(e->type, sizeof(e->type), "%s", type ? type : "String");
|
||||
e->has_default = (int)(long)has_default_v;
|
||||
e->required = (int)(long)required_v;
|
||||
/* Resolution order: environment wins, declaration supplies the fallback. */
|
||||
const char* env = getenv(name);
|
||||
if (env && *env) e->value = el_strdup_persist(env);
|
||||
else if (e->has_default && def) e->value = el_strdup_persist(def);
|
||||
else e->value = NULL;
|
||||
return EL_NULL;
|
||||
}
|
||||
|
||||
/* el_config_validate — check the whole schema at once, before main() runs.
|
||||
* Reports EVERY problem, not just the first: a startup that fails one variable
|
||||
* at a time costs one restart per variable. */
|
||||
el_val_t el_config_validate(el_val_t program_v) {
|
||||
const char* prog = EL_CSTR(program_v);
|
||||
int bad = 0;
|
||||
for (int i = 0; i < el_config_n; i++) {
|
||||
ElConfigEntry* e = &el_config_tab[i];
|
||||
if (!e->value) {
|
||||
if (e->required) {
|
||||
fprintf(stderr, "[el] config: %s is required but is not set "
|
||||
"(no value in the environment, no default declared)\n", e->name);
|
||||
bad++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (strcmp(e->type, "Int") == 0 && !el_config_is_int(e->value)) {
|
||||
fprintf(stderr, "[el] config: %s is declared Int but its value is \"%s\"\n",
|
||||
e->name, e->value);
|
||||
bad++;
|
||||
}
|
||||
}
|
||||
if (bad) {
|
||||
fprintf(stderr, "[el] FATAL: program '%s' has %d invalid configuration "
|
||||
"entr%s. Refusing to start.\n",
|
||||
prog ? prog : "?", bad, bad == 1 ? "y" : "ies");
|
||||
exit(1);
|
||||
}
|
||||
return EL_NULL;
|
||||
}
|
||||
|
||||
/* config — read a configuration value.
|
||||
*
|
||||
* When the program declared a schema, that schema is authoritative: the value
|
||||
* has already been resolved and validated at startup, so this is a lookup and
|
||||
* NOT a place where a default gets decided. Reading a key that was never
|
||||
* declared is a bug at the read site, and is reported as one — that enforcement
|
||||
* is what makes the declaration real rather than advisory.
|
||||
*
|
||||
* With no schema declared, behaviour is unchanged (plain getenv), so programs
|
||||
* that have not migrated keep working. */
|
||||
el_val_t config(el_val_t key_v) {
|
||||
const char* key = EL_CSTR(key_v);
|
||||
if (!key || !*key) return EL_STR("");
|
||||
if (el_config_has_schema) {
|
||||
for (int i = 0; i < el_config_n; i++) {
|
||||
if (strcmp(el_config_tab[i].name, key) == 0) {
|
||||
const char* v = el_config_tab[i].value;
|
||||
return el_wrap_str(el_strdup(v ? v : ""));
|
||||
}
|
||||
}
|
||||
fprintf(stderr, "[el] FATAL: config(\"%s\") is not declared in the "
|
||||
"program block. Declare it there, with its default, or stop "
|
||||
"reading it.\n", key);
|
||||
exit(1);
|
||||
}
|
||||
const char* val = getenv(key);
|
||||
if (!val) return EL_STR("");
|
||||
return el_wrap_str(el_strdup(val));
|
||||
|
||||
@@ -666,6 +666,10 @@ el_val_t engram_get_node(el_val_t id);
|
||||
void engram_strengthen(el_val_t node_id);
|
||||
void engram_forget(el_val_t node_id);
|
||||
el_val_t engram_prune_telemetry(el_val_t older_than_ms);
|
||||
/* Largest byte length <= max_bytes that does not split a UTF-8 codepoint.
|
||||
* Bounded by bytes, not codepoints, so truncated strings never grow. */
|
||||
size_t el_utf8_safe_len(const char* s, size_t max_bytes);
|
||||
|
||||
el_val_t engram_node_count(void);
|
||||
/* Attach a Geometry to an existing node, and read the attached width back.
|
||||
* Named for the operation, not the store: a node acquires geometry. This is
|
||||
@@ -1023,6 +1027,22 @@ el_val_t __url_decode(el_val_t s);
|
||||
/* Environment */
|
||||
el_val_t __env_get(el_val_t key);
|
||||
|
||||
/* Cross-cutting concerns declared by a `program` block (spec §18).
|
||||
* All three are COMPILER-INJECTED at the head of main() — they are not meant to
|
||||
* be written by hand, which is the point: the guarantee cannot be forgotten at a
|
||||
* call site because there is no call site. */
|
||||
el_val_t el_singleton_acquire(el_val_t id); /* §18.1 process identity */
|
||||
el_val_t el_config_declare(el_val_t name, el_val_t type,
|
||||
el_val_t deflt, el_val_t has_default,
|
||||
el_val_t required); /* §18.2 config schema */
|
||||
el_val_t el_config_validate(el_val_t program_name); /* §18.2 startup validate */
|
||||
|
||||
/* config(key) — the READ side, and the only one programs write by hand. With a
|
||||
* schema declared it is a validated lookup; without one it degrades to getenv.
|
||||
* (Defined in el_runtime.c but previously never prototyped here, so any program
|
||||
* calling it failed to compile under -Werror=implicit-function-declaration.) */
|
||||
el_val_t config(el_val_t key);
|
||||
|
||||
/* Subprocess */
|
||||
el_val_t __exec(el_val_t cmd);
|
||||
el_val_t __exec_bg(el_val_t cmd);
|
||||
|
||||
+169
-5
@@ -29,6 +29,8 @@ This section is the **single source of truth** for what works and what is planne
|
||||
- Lexer: keywords, identifiers, integer/float/string/bool literals, operators below.
|
||||
- Parser: `let`, `return`, `fn`, `type`, `enum`, `import`, `from … import`, `while`, `for`, `if/else if/else`, `match`, `@decorator`, array/map literals, all listed operators, function calls, field access, index access, unary `!`/`-`, postfix `?`.
|
||||
- Codegen: function definitions, top-level `main()`, all expression forms above, control flow, decorator-as-AST-attachment.
|
||||
- Boundary seam: decorator arguments and stacking; VBD role enforcement via `#error`; `engram_boundary_beat` auto-emit at `@manager`/`@accessor` entry; `@route` dispatch tables (Section 9).
|
||||
- Program-level declarative blocks: `cgi`, `service`, and `program` — the last carrying process identity and configuration (Section 18).
|
||||
- C runtime: I/O, string operations, integer math, lists, maps, filesystem, command-line args, basic `json_get` substring lookup.
|
||||
|
||||
### Planned (in flight)
|
||||
@@ -37,7 +39,7 @@ This section is the **single source of truth** for what works and what is planne
|
||||
- **Match codegen.** Currently parsed; codegen does not emit. Adding `({ ... })` statement-expression emission.
|
||||
- **`?` propagation.** Currently no-op. Adding nil-propagation semantics.
|
||||
- **`cgi` block parsing.** Currently lexed (`cgi` is a keyword) but not parsed as a statement. Adding `parse_cgi_block` and codegen of `el_cgi_init` at the head of `main()`.
|
||||
- **VBD role enforcement.** `@manager`/`@engine`/`@accessor` are accepted as decorators but not enforced. Adding compile-time check that `dharma_emit`/`dharma_field` only appear inside `@manager` functions.
|
||||
- **Boundary epilogues.** The decorator seam injects a prologue only. Adding prologue/epilogue wrapping, the prerequisite for durability-as-an-effect (Section 19.1).
|
||||
- **`vessel` keyword.** Replaces `package` in manifests. Adding to lexer.
|
||||
- **Real `engram_*` runtime.** Currently stub. Adding in-process graph store with spreading activation, Hebbian strengthening, and disk persistence — see Section 16.4.
|
||||
- **Real `dharma_*` runtime.** Currently stub. Adding network transport, channel registry, identity resolution.
|
||||
@@ -96,8 +98,10 @@ The following words are reserved and cannot be used as identifiers. Each row not
|
||||
| `while` | yes | Loop |
|
||||
| `import` / `from` / `as` | yes | Module import |
|
||||
| `true` / `false` | yes | Bool literals |
|
||||
| `cgi` | planned | Top-level CGI declaration block |
|
||||
| `manager` / `engine` / `accessor` | as decorators | VBD role marker on `fn` (enforcement planned) |
|
||||
| `cgi` | yes | Top-level CGI declaration block |
|
||||
| `service` | yes | Top-level capability-bounded declaration block |
|
||||
| `program` | yes | Top-level cross-cutting declaration block (Section 18) |
|
||||
| `manager` / `engine` / `accessor` | as decorators | VBD role marker on `fn`; enforcement and boundary auto-emit are live (Section 9) |
|
||||
| `vessel` | planned | Manifest declaration (replaces `package`) |
|
||||
| `activate` / `where` | planned | Spreading-activation construct |
|
||||
| `sealed` | planned | Capability scope block |
|
||||
@@ -446,9 +450,21 @@ Parsed. The module name is recorded; the brace-list is consumed. Both forms prod
|
||||
fn handle(channel: String, msg: String) -> Void { … }
|
||||
```
|
||||
|
||||
The `@` token followed by an identifier attaches a decorator name to the next `FnDef`. Decorators with structural meaning today: none. Planned enforcement (Section 16.2): VBD roles `@manager`, `@engine`, `@accessor`.
|
||||
The `@` token followed by an identifier attaches a decorator to the next `FnDef`.
|
||||
|
||||
Non-VBD decorators are accepted and ignored.
|
||||
**Decorators take arguments and they stack.** `@route("/p", "GET") @manager fn f()` attaches both to `f` as a `decorators` list of `{name, args}` records, topmost-first. Arguments are string literals only.
|
||||
|
||||
**Decorators have structural meaning today.** This is El's function-level boundary seam — the mechanism by which a cross-cutting concern is handled *at the boundary* rather than by a convention repeated at every call site:
|
||||
|
||||
| Decorator | Structural effect |
|
||||
|---|---|
|
||||
| `@manager` | Permits calls to `dharma_emit` / `dharma_field`. Calling either from a non-`@manager` fn emits a `#error` into the generated C — a compile-time failure, not a lint. |
|
||||
| `@manager`, `@accessor` | Codegen injects one call to `engram_boundary_beat(<fn name>)` at function entry. The decorated op self-reports (chrono tick, afferent counter, self-activity strengthen, dharma bus event) with **zero** hand-written instrumentation in its body. |
|
||||
| `@route(path, method, …)` | Records a route into a generated dispatch table. |
|
||||
|
||||
Decorators with no registered meaning are accepted and ignored.
|
||||
|
||||
**Limits of the seam, as it stands.** The injection is a *prologue only* — there is no epilogue, no wrapping of the call, and no way for a decorator to run code after the body returns. The injected callee is a fixed builtin chosen by the compiler, not derived from the decorator name or its arguments. Section 19 depends on lifting exactly these two limits.
|
||||
|
||||
---
|
||||
|
||||
@@ -1088,4 +1104,152 @@ The next minor version closes the implementation gaps named in this document. Tr
|
||||
|
||||
---
|
||||
|
||||
## 18. The Program Block — cross-cutting concerns [implemented]
|
||||
|
||||
### 18.0 Why this exists
|
||||
|
||||
A cross-cutting concern is one that belongs to the *process*, not to any function in it: only one of me may run; this is what my configuration is; every mutation must be durable; every request must be authorized.
|
||||
|
||||
El's units of encapsulation are the function and the module. Neither can hold a concern like that. 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. They are not enforced by anything, they are invisible in review, and they fail silently at the one site somebody forgot.
|
||||
|
||||
Measured in this codebase before this section existed:
|
||||
|
||||
| Concern | State | What the convention was |
|
||||
|---|---|---|
|
||||
| process identity | **zero** guards anywhere — no pidfile, no lock, no already-running check, at any layer | "check nothing is already running first" |
|
||||
| configuration | **20** distinct environment variables in one program, each with its default written inline at the read site | "remember the right default here" |
|
||||
| durability | **62** `persist_*` / `engram_save` / `wal_*` / `checkpoint` call sites | "after you mutate, remember to persist" |
|
||||
| request auth | **10** per-route `_auth` checks | "check the token in this handler too" |
|
||||
|
||||
These are not four problems. They are one absence, four times.
|
||||
|
||||
That the convention form fails is observed, not predicted. Process identity failed three times in a single day: twice, two engram processes ran simultaneously against the same data directory; twice, a stale binary held a port and answered probes while a fresh build was believed to be under test, because `pkill -f` had silently failed to match its argv — which nearly produced a false "the fix does not work" conclusion. Configuration failed structurally: `ENGRAM_DATA_DIR` was read at six sites, five of them dead bindings, and the sixth defaulted to `/tmp/engram` — contradicting the canonical resolver's `$HOME/.neuron/engram` and landing a pre-destructive safety backup on ephemeral storage.
|
||||
|
||||
The `program` block is where a concern of this shape is declared once and enforced by the compiler at the process boundary.
|
||||
|
||||
### 18.1 Syntax
|
||||
|
||||
```
|
||||
program "engram" {
|
||||
singleton: "engram"
|
||||
env ENGRAM_BIND: String = ":8742"
|
||||
env GUIDE_PORT: Int = "8771"
|
||||
env ENGRAM_API_KEY: String required
|
||||
}
|
||||
```
|
||||
|
||||
At most one `program` block per program. It composes with `cgi` and `service` — those declare what a program *may do*; `program` declares what a program *is*.
|
||||
|
||||
Grammar:
|
||||
|
||||
```ebnf
|
||||
program_block = "program" string "{" { program_field } "}" ;
|
||||
program_field = singleton_field | env_field ;
|
||||
singleton_field = "singleton" ":" string [ "," ] ;
|
||||
env_field = "env" ident ":" type
|
||||
[ "=" string ] [ "required" ] [ "," ] ;
|
||||
```
|
||||
|
||||
`singleton` and `env` are **not** reserved words. They are read as identifier token values by the block's own parse loop, so they remain usable as ordinary identifiers everywhere else. `program` is the only keyword this section adds.
|
||||
|
||||
### 18.2 Process identity — `singleton`
|
||||
|
||||
`singleton: "id"` compiles to an `el_singleton_acquire("id")` call injected as the **first statement of `main()`**, before any user statement runs.
|
||||
|
||||
The runtime takes an exclusive non-blocking `flock` on `<dir>/el-singleton-<id>.lock`, where `<dir>` is `$EL_SINGLETON_DIR`, else `$TMPDIR`, else `/tmp`. On success it writes its pid and holds the descriptor open for the life of the process. On contention it **refuses to start**: it reports the holder's pid, names the lock file, and exits 1.
|
||||
|
||||
Two properties are deliberate:
|
||||
|
||||
- **It is a lock, not a pidfile.** The kernel releases an `flock` when the owning process dies — including on `SIGKILL` and on crash. There is therefore no stale-lock state, and so no "delete the lock file to get unstuck" recovery ritual. Such a ritual would itself be a convention, which is the thing this section exists to remove.
|
||||
- **It reports the holder's pid.** "Already running" is not actionable. A pid is. This is the direct answer to the observed failure where a stale process survived a `pkill` and went on answering probes.
|
||||
|
||||
Refusal is loud and total. It is not a warning, and the program does not continue degraded. This 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. `singleton` refuses before the first side effect.
|
||||
|
||||
### 18.3 Configuration — `env`
|
||||
|
||||
Each `env` entry declares one configuration variable: its name, its type (`Int` or `String`), and either a default or `required`.
|
||||
|
||||
Resolution happens once, at startup, in declaration order: **the environment wins; the declaration supplies the fallback.** Then `el_config_validate` checks the whole schema and reports *every* problem at once before exiting — a startup that fails one variable at a time costs one restart per variable.
|
||||
|
||||
Values are read with `config("NAME")`, which returns a `String`.
|
||||
|
||||
The enforcement that makes the declaration real: **once a program block exists, `config("X")` for an undeclared `X` is a fatal error.** Without that, the schema would be advisory, and an advisory schema is just another convention. Programs with no `program` block are unaffected — `config()` falls back to a plain environment read, so migration is incremental and per-program.
|
||||
|
||||
The point is not that configuration is now centralized. It is that **a default is no longer a decision made at a read site.** A read site cannot disagree with another read site about what a variable means, because a read site no longer says.
|
||||
|
||||
### 18.4 What is deliberately not declared here
|
||||
|
||||
Some values look like configuration and are not. `ENGRAM_DATA_DIR` already has a single owner — `engram_resolve_data_dir()`, which resolves it, creates the directory, and fails loud rather than silently persisting to an ephemeral path. Declaring it in the `program` block as well would give it two owners that can disagree, recreating the precise defect this section removes.
|
||||
|
||||
The rule: **a variable belongs in the program block when the block would be its only owner.** If a resolver already owns it, leave it there.
|
||||
|
||||
`HOME` is likewise not configuration. It is an environment fact, and stays a raw `env()` read.
|
||||
|
||||
---
|
||||
|
||||
## 19. Boundary Effects — durability and request authorization [design only, not implemented]
|
||||
|
||||
Sections 19.1 and 19.2 specify the two remaining concerns from the table in 18.0. Both are **designed and deliberately unimplemented.** The reason is stated in 19.3 and it is not difficulty.
|
||||
|
||||
### 19.1 Durability as an epilogue effect
|
||||
|
||||
**The defect.** 62 call sites carry the convention *"after you mutate, remember to persist."* This is structurally the same defect as the index bug being fixed elsewhere in this tree — *"after you append, remember to index"* — which failed at **9 of 9** sites. A convention that failed at 100% of its sites is the strongest available evidence about what this class of convention is worth.
|
||||
|
||||
**Why the existing seam cannot express it.** §9's injection is a prologue. Durability is inherently an *epilogue*: persist after the mutation succeeds, and not at all if it threw. The seam has no epilogue.
|
||||
|
||||
**Design.** Extend the decorator seam from prologue-only to prologue/epilogue, then declare durability as an effect on the mutating function:
|
||||
|
||||
```
|
||||
@durable("engram")
|
||||
fn engram_write_node(id: String, body: String) -> Bool { … }
|
||||
```
|
||||
|
||||
Codegen wraps rather than prefixes:
|
||||
|
||||
```c
|
||||
el_val_t engram_write_node(el_val_t id, el_val_t body) {
|
||||
el_effect_enter(EL_STR("durable"), EL_STR("engram"));
|
||||
el_val_t __r = /* original body */;
|
||||
el_effect_exit(EL_STR("durable"), EL_STR("engram"), __r);
|
||||
return __r;
|
||||
}
|
||||
```
|
||||
|
||||
`el_effect_exit` is where the persist happens, and it is the only place it happens. Two properties follow that the 62 hand-written sites cannot have:
|
||||
|
||||
- **Coalescing.** The epilogue is a single choke point, so N mutations inside one request can produce one fsync instead of N. The hand-written form cannot coalesce, because no site knows about the others.
|
||||
- **Failure is not silent.** A persist that fails inside `el_effect_exit` can force the mutation's return value to failure. A forgotten `persist_*` call cannot fail — it simply does not happen, which is exactly why the defect is invisible.
|
||||
|
||||
**Enforcement, and this is the part that actually fixes it.** Mirroring §9's `#error` for `dharma_emit`: a function that calls a mutating primitive without carrying `@durable` is a **compile error**. Otherwise this is a 63rd thing to remember rather than a replacement for 62.
|
||||
|
||||
### 19.2 Request authorization as a route effect
|
||||
|
||||
**The defect.** 10 per-route `_auth` checks. The HTTP layer has no concept of authorization, so a new route is unauthenticated by default and silently so — the failure mode is a route that forgot, and nothing anywhere reports it.
|
||||
|
||||
**Design.** Authorization becomes an argument to the `@route` decorator, which already takes arguments and already builds a dispatch table:
|
||||
|
||||
```
|
||||
@route("/api/write", "POST", auth: "required")
|
||||
fn route_write(body: String) -> String { … }
|
||||
```
|
||||
|
||||
The generated dispatcher performs the check **before** dispatch, so an unauthorized request never reaches the handler and the handler contains no auth code at all.
|
||||
|
||||
The default must be `required`. A route that says nothing gets authorization; opening one up takes an explicit `auth: "public"`. Defaulting to public preserves the current failure mode exactly — forgetting stays silent — and a default that preserves the defect is not a fix.
|
||||
|
||||
Route inventory falls out for free: the dispatch table already exists, so the compiler can emit the full route/auth matrix and make "which routes are public" a fact that is read rather than audited.
|
||||
|
||||
### 19.3 Why these are not implemented
|
||||
|
||||
Not difficulty — **collision**. Both land squarely in regions two other agents hold right now:
|
||||
|
||||
- **Durability** requires changing the mutation and persist paths in `lang/runtime/el_runtime.c` and `engram/src/server.el` — the same files and the same read/write paths being restructured by concurrent work on VIndex read-path mutation and memory ownership, and on geometry-as-an-el-value and `transduce`.
|
||||
- **Request auth** requires changing route dispatch in `engram/src/server.el`, which the geometry/`transduce` work is actively reshaping.
|
||||
|
||||
Implementing either now would mean editing files under concurrent modification and resolving conflicts in exactly the paths whose correctness is currently under repair. The designs are recorded here so the work is not lost, and so that whoever lands them does so against a settled tree.
|
||||
|
||||
The prerequisite for 19.1 is the same in both cases: **lift the §9 seam from prologue-only to prologue/epilogue.** That change is independent of both collisions and can land first.
|
||||
|
||||
---
|
||||
|
||||
End of specification.
|
||||
|
||||
Reference in New Issue
Block a user