prevent engram corruption: idempotent boot seeding, session-start event cap

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.
This commit is contained in:
2026-06-29 11:09:01 -05:00
parent 933547265e
commit 51bea5507b
4 changed files with 91 additions and 9 deletions
Generated Vendored
+43 -1
View File
@@ -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;
}
+21 -3
View File
@@ -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(
+6 -5
View File
@@ -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 idindex 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 + "\"}"
}
+21
View File
@@ -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 + ")")
}