From edcec3bdf435d6b467dcb84ce261b68d260e9cbd Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 10 Aug 2026 16:44:17 -0500 Subject: [PATCH] engram: add /api/nodes/reseed so a node body can be repaired at its own id Two write paths could put a node in the graph and neither could put a body on an id that already exists. POST /api/nodes mints a fresh id via engram_node_full; POST /api/load-merge honors a declared id but skips anything already present. That is right for the additive case and leaves a hole: a node resident with a truncated body cannot be repaired. Forge's genesis seed sits in that hole. Two of Neuron's identity nodes carry only their own label as content -- 30 and 22 bytes against 4263 and 2590 declared. Their ids are load-bearing (is_protected_node keys on them and 214 declared edges reference them), so recreating them under a new id is not a repair, it is a second break. Engram has no in-place node update, so a replace is forget-then-merge, and engram_forget also drops every incident edge -- 85 and 93 on those two nodes, nearly all tag edges and accumulated hebbian associations the seed does not declare and could not restore. preserve_edges (default true) therefore snapshots before the forget and re-merges after: the replaced node is back by then so it is skipped, and every dropped edge returns through the (from_id,to_id,relation) dedup. The same re-merge is the failure path -- if the seed merge does not produce the node, the backup puts the original back. Rollback, not data loss. With no replace list the route is exactly /api/load-merge. Verified on a sandbox engram seeded to mirror the live graph's state for this seed (15 resident nodes, 694 incident edges): 87 nodes created at their declared ids, 2 replaced in place, 214/214 edges laid, 682/682 non-seed incident edges preserved, and a second run reports 0 added. --- engram/src/server.el | 89 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/engram/src/server.el b/engram/src/server.el index 3372ff6..c2f6f90 100644 --- a/engram/src/server.el +++ b/engram/src/server.el @@ -272,6 +272,89 @@ fn route_load_merge(method: String, path: String, body: String) -> String { "{\"ok\":true,\"nodes_added\":" + int_to_str(added_n) + ",\"edges_added\":" + int_to_str(added_e) + ",\"node_count\":" + int_to_str(engram_node_count()) + "}" } +// route_reseed_nodes — POST /api/nodes/reseed +// {"path": "", "replace": ["", ...], +// "preserve_edges": true, "_auth": ""} +// +// ID-PRESERVING install/repair for declarative seed graphs. +// +// WHY THIS EXISTS (2026-08-10). Two write paths could put a node into the +// graph and neither can put a BODY onto an id that already exists: +// POST /api/nodes mints a fresh id via engram_node_full, and +// POST /api/load-merge honors the declared id but SKIPS anything already +// present. That is exactly right for the additive case and leaves one hole: +// a node that exists with a truncated body. Forge's genesis seed hit it — +// two identity nodes (Voice, Voice Craft) sat in the graph carrying only +// their own label as content, 30 and 22 bytes against 4263 and 2590 in the +// seed. Their ids are load-bearing (is_protected_node keys on them and 214 +// declared edges reference them), so "delete and recreate with a new id" is +// not a repair, it is a second break. +// +// Mechanism: engram has no in-place node update, so a replace is +// forget-then-merge. engram_forget also drops every INCIDENT EDGE — for +// those two nodes that is 85 and 93 edges, almost all of them tag edges and +// accumulated hebbian associations that the seed does not declare and could +// not restore. preserve_edges (default true) therefore snapshots the graph +// before the forget and re-merges that snapshot afterwards: the replaced +// node is back by then so it is skipped, and every dropped incident edge +// returns through load_merge's (from_id,to_id,relation) dedup. The same +// re-merge is the failure path — if the seed merge does not produce the +// node, the backup puts the original back. Rollback, not data loss. +// +// preserve_edges=false skips the two snapshot round-trips (cheap, lossy); +// use it only on a graph whose edges are fully declared by the seed. +// With no "replace" list this route is exactly /api/load-merge. +fn route_reseed_nodes(method: String, path: String, body: String) -> String { + let p: String = json_get_string(body, "path") + 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 } + let backup: String = dir + "/.reseed-backup.json" + + let replace_raw: String = json_get_raw(body, "replace") + let n_replace: Int = json_array_len(replace_raw) + // Presence-aware: absent key means "preserve", only an explicit false opts out. + let pe_raw: String = json_get_raw(body, "preserve_edges") + let preserve: Bool = !str_eq(pe_raw, "false") + + let before_n: Int = engram_node_count() + let before_e: Int = engram_edge_count() + + let replaced: Int = 0 + if n_replace > 0 { + if preserve { engram_save(backup) } + let i: Int = 0 + while i < n_replace { + let rid: String = json_array_get_string(replace_raw, i) + if !str_eq(rid, "") { + // engram_get_node_json returns "{}" for a miss — only forget + // ids that are actually resident, so a typo in the replace + // list is a no-op rather than a silent partial run. + let existing: String = engram_get_node_json(rid) + if !str_eq(existing, "{}") { + engram_forget(rid) + let replaced = replaced + 1 + } + } + let i = i + 1 + } + } + + engram_load_merge(p) + if replaced > 0 { + if preserve { engram_load_merge(backup) } + } + + let saved: Int = persist_canonical() + "{\"ok\":true,\"replaced\":" + int_to_str(replaced) + + ",\"nodes_added\":" + int_to_str(engram_node_count() - before_n) + + ",\"edges_added\":" + int_to_str(engram_edge_count() - before_e) + + ",\"node_count\":" + int_to_str(engram_node_count()) + + ",\"edge_count\":" + int_to_str(engram_edge_count()) + "}" +} + // route_emit_ise — write an InternalStateEvent node from the soul daemon. // // Endpoint: POST /api/neuron/state-events @@ -419,6 +502,12 @@ fn handle_request(method: String, path: String, body: String) -> String { } // Nodes + // Reseed must be tested before the exact "/api/nodes" match below reads + // as the general create path — order is not load-bearing (the match is + // exact) but keeping them adjacent keeps them from drifting apart. + if str_eq(method, "POST") && (str_eq(clean, "/api/nodes/reseed") || str_eq(clean, "/nodes/reseed")) { + return route_reseed_nodes(method, path, body) + } if str_eq(method, "POST") && (str_eq(clean, "/api/nodes") || str_eq(clean, "/nodes")) { return route_create_node(method, path, body) }