From 51bea5507b63843a4a884e973868a0d9605c52d4 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 29 Jun 2026 11:09:01 -0500 Subject: [PATCH] prevent engram corruption: idempotent boot seeding, session-start event cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1: mem_boot_count_inc prunes all existing soul:boot_count nodes before inserting the new one — keeps exactly one boot counter node instead of accumulating a new node per boot. Also fixes a latent ordering bug where engram_search_json oldest-first results caused the counter to read stale (low) values once >3 copies accumulated. Fix 3: handle_api_node_delete comment clarified — the no-verify exception is correct for deletes (not a write path); read-back-verify is for writes only. Fix 4: emit_session_start_event prunes old session-start InternalStateEvent nodes after each boot, keeping the 10 most recent and forgetting older ones. Prevents unbounded accumulation of ~120+ copies. --- dist/soul.c | 44 +++++++++++++++++++++++++++++++++++++++++++- memory.el | 24 +++++++++++++++++++++--- neuron-api.el | 11 ++++++----- soul.el | 21 +++++++++++++++++++++ 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/dist/soul.c b/dist/soul.c index 36af4a0..2c3d3ae 100644 --- a/dist/soul.c +++ b/dist/soul.c @@ -25343,9 +25343,31 @@ el_val_t mem_boot_count_get(void) { el_val_t mem_boot_count_inc(void) { el_val_t current = mem_boot_count_get(); el_val_t next = (current + 1); + /* Prune all existing soul:boot_count nodes — keep exactly one. */ + el_val_t old_results = engram_search_json(EL_STR("soul:boot_count"), 50); + if (!str_eq(old_results, EL_STR("")) && !str_eq(old_results, EL_STR("[]"))) { + el_val_t old_len = json_array_len(old_results); + el_val_t oi = 0; + while (oi < old_len) { + el_val_t old_node = json_array_get(old_results, oi); + el_val_t old_id = json_get(old_node, EL_STR("id")); + if (!str_eq(old_id, EL_STR(""))) { + (void)(engram_forget(old_id)); + } + oi = (oi + 1); + } + } el_val_t content = el_str_concat(EL_STR("soul:boot_count:"), int_to_str(next)); el_val_t tags = EL_STR("[\"soul-meta\",\"boot-counter\"]"); - el_val_t discard = engram_node_full(content, EL_STR("Memory"), EL_STR("soul:boot_count"), el_from_float(el_from_float(0.9)), el_from_float(el_from_float(0.9)), el_from_float(el_from_float(1.0)), EL_STR("Canonical"), tags); + el_val_t boot_node_id = engram_node_full(content, EL_STR("Memory"), EL_STR("soul:boot_count"), el_from_float(el_from_float(0.9)), el_from_float(el_from_float(0.9)), el_from_float(el_from_float(1.0)), EL_STR("Canonical"), tags); + if (str_eq(boot_node_id, EL_STR(""))) { + println(el_str_concat(el_str_concat(EL_STR("[memory] mem_boot_count_inc: write rejected (empty id) — boot counter node lost (count="), int_to_str(next)), EL_STR(")"))); + return next; + } + el_val_t boot_readback = engram_get_node_json(boot_node_id); + if (str_eq(boot_readback, EL_STR("")) || str_eq(boot_readback, EL_STR("{}"))) { + println(el_str_concat(el_str_concat(el_str_concat(EL_STR("[memory] mem_boot_count_inc: WRITE VERIFY FAILED id="), boot_node_id), EL_STR(" count=")), int_to_str(next))); + } return next; return 0; } @@ -29421,6 +29443,26 @@ el_val_t emit_session_start_event(void) { el_val_t payload = el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"event\":\"session_start\""), EL_STR(",\"boot\":")), boot_num), EL_STR(",\"cgi\":\"")), eff_cgi), EL_STR("\"")), EL_STR(",\"node_count\":")), int_to_str(node_ct)), EL_STR(",\"edge_count\":")), int_to_str(edge_ct)), EL_STR(",\"identity_loaded\":")), has_identity), EL_STR(",\"ts\":")), int_to_str(ts)), EL_STR("}")); el_val_t tags = EL_STR("[\"internal-state\",\"session-start\",\"InternalStateEvent\"]"); el_val_t discard = engram_node_full(payload, EL_STR("InternalStateEvent"), EL_STR("session-start"), el_from_float(el_from_float(0.9)), el_from_float(el_from_float(0.9)), el_from_float(el_from_float(1.0)), EL_STR("Episodic"), tags); + /* Prune accumulated session-start events — keep the 10 most recent. + * engram_search_json returns oldest-first, so forget from index 0 to (count-11). */ + el_val_t keep_n = 10; + el_val_t old_events = engram_search_json(EL_STR("session-start InternalStateEvent"), 200); + if (!str_eq(old_events, EL_STR("")) && !str_eq(old_events, EL_STR("[]"))) { + el_val_t ev_count = json_array_len(old_events); + if (ev_count > keep_n) { + el_val_t prune_to = (ev_count - keep_n); + el_val_t ei = 0; + while (ei < prune_to) { + el_val_t old_ev = json_array_get(old_events, ei); + el_val_t old_ev_id = json_get(old_ev, EL_STR("id")); + if (!str_eq(old_ev_id, EL_STR(""))) { + (void)(engram_forget(old_ev_id)); + } + ei = (ei + 1); + } + println(el_str_concat(el_str_concat(el_str_concat(EL_STR("[soul] pruned "), int_to_str(prune_to)), EL_STR(" old session-start events (kept 10)")), EL_STR(""))); + } + } println(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("[soul] session-start event logged (boot="), boot_num), EL_STR(" nodes=")), int_to_str(node_ct)), EL_STR(" edges=")), int_to_str(edge_ct)), EL_STR(")"))); return 0; } diff --git a/memory.el b/memory.el index 0265eed..bc14505 100644 --- a/memory.el +++ b/memory.el @@ -134,12 +134,30 @@ fn mem_boot_count_get() -> Int { return str_to_int(num_str) } -// mem_boot_count_inc — increment boot counter, store new node, return new count. -// Each boot creates a new "soul:boot_count:N" node. Old ones accumulate as -// history — the search above always returns the highest value seen. +// mem_boot_count_inc — increment boot counter, store a single canonical node, return new count. +// Prunes ALL existing soul:boot_count nodes before inserting the new one so there is +// always at most ONE such node in the graph. Without pruning, engram_node_full inserts +// a new node every boot (no upsert) and the old ones accumulate. The search-first +// approach also fixes a latent ordering bug: engram_search_json returns oldest-first, +// so mem_boot_count_get() with limit=3 would read a stale (lower) count once more +// than 3 copies accumulate. fn mem_boot_count_inc() -> Int { let current: Int = mem_boot_count_get() let next: Int = current + 1 + // Prune all existing boot_count nodes — keep exactly one. + let old_results: String = engram_search_json("soul:boot_count", 50) + if !str_eq(old_results, "") && !str_eq(old_results, "[]") { + let old_len: Int = json_array_len(old_results) + let oi: Int = 0 + while oi < old_len { + let old_node: String = json_array_get(old_results, oi) + let old_id: String = json_get(old_node, "id") + if !str_eq(old_id, "") { + engram_forget(old_id) + } + let oi = oi + 1 + } + } let content: String = "soul:boot_count:" + int_to_str(next) let tags: String = "[\"soul-meta\",\"boot-counter\"]" let boot_node_id: String = engram_node_full( diff --git a/neuron-api.el b/neuron-api.el index ec8e3f1..1ad8770 100644 --- a/neuron-api.el +++ b/neuron-api.el @@ -196,11 +196,12 @@ fn handle_api_node_create(body: String) -> String { fn handle_api_node_delete(body: String) -> String { let id: String = json_get(body, "id") if str_eq(id, "") { return api_err("id is required") } - // engram_forget removes the node + its incident edges from the live graph. We do - // NOT read-back-verify here: engram_get_node_json can return a STALE hit for a just- - // removed id (the id->index map is not rebuilt on forget), which would produce a - // false "delete_failed" even though the node is gone. The graph endpoints - // (/api/graph/nodes) correctly reflect the removal, which is the source of truth. + // engram_forget removes the node + its incident edges from the live graph. + // Delete is NOT read-back-verified: engram_get_node_json can return a stale hit + // for a just-forgotten id because the id→index map is not rebuilt on forget. + // A stale hit would cause a false "delete_failed" on a successful deletion. + // This exception is correct: read-back-verify guards WRITES; for deletes, + // the graph endpoints (/api/graph/nodes) reflect the removal and are the source of truth. engram_forget(id) return "{\"ok\":true,\"id\":\"" + id + "\"}" } diff --git a/soul.el b/soul.el index 0c981a3..2387e77 100644 --- a/soul.el +++ b/soul.el @@ -346,6 +346,27 @@ fn emit_session_start_event() -> Void { el_from_float(0.9), el_from_float(0.9), el_from_float(1.0), "Episodic", tags ) + // Prune accumulated session-start events — keep the 10 most recent. + // engram_search_json returns results in insertion order (oldest first), so + // results[0..count-11] are the oldest; forgetting them leaves the newest 10. + let keep_n: Int = 10 + let old_events: String = engram_search_json("session-start InternalStateEvent", 200) + if !str_eq(old_events, "") && !str_eq(old_events, "[]") { + let ev_count: Int = json_array_len(old_events) + if ev_count > keep_n { + let prune_to: Int = ev_count - keep_n + let ei: Int = 0 + while ei < prune_to { + let old_ev: String = json_array_get(old_events, ei) + let old_ev_id: String = json_get(old_ev, "id") + if !str_eq(old_ev_id, "") { + engram_forget(old_ev_id) + } + let ei = ei + 1 + } + println("[soul] pruned " + int_to_str(prune_to) + " old session-start events (kept " + int_to_str(keep_n) + ")") + } + } println("[soul] session-start event logged (boot=" + boot_num + " nodes=" + int_to_str(node_ct) + " edges=" + int_to_str(edge_ct) + " prev_summary=" + has_prev_sum + ")") }