Compare commits

...

3 Commits

Author SHA1 Message Date
will.anderson a45a3ca379 Fix truncated POST/GET /api/safety-contact response
Saving the 988 crisis-line contact returned truncated, unparseable JSON —
cut mid-"set_at" at the file's byte length (e.g. 178 of a 218-byte
response). The contact written to disk was complete; only the HTTP response
was clipped, so a real customer's crisis-contact save came back corrupt.

Root cause is in the el runtime's response writer, not a handler buffer:
fs_read stores the file's byte count in a thread-local (_tl_fs_read_len)
for binary-safe file serving, and the response writer uses that length when
non-zero instead of strlen(body) (el_runtime.c:1409). Both safety-contact
handlers call fs_read (the POST read-back verify; the GET file read) and
then return a LONGER wrapped JSON string, so the response is capped to the
file size.

Soul-source fix (no runtime change needed):
- POST: verify persistence via fs_write's return (1 = all bytes written)
  instead of an fs_read read-back — removes the fs_read, so nothing caps the
  response.
- GET: fs_read is required, so reset the thread-local after it with a no-op
  fs_read("") (fs_read zeroes the length before it opens a path) so the
  wrapped response is sent in full.

Verified: POST (crisis-line + custom) and GET now return complete, valid
JSON (parses cleanly, full contact incl. set_at). Regenerated dist/soul.c +
dist/safety.c (3GB RSS watchdog, release el_runtime v1.0.0-20260501).
Full suite still green: verify-soul-contract GATE PASS (PRESENCE +
IMMUTABILITY), genesis boot survives (/health 200, no segfault), bounded-
persona floor still compiled in.

NOTE: the underlying runtime leak (any handler that fs_reads then returns a
longer string) is worth a proper fix in el_runtime.c (use the max of
strlen and _tl_fs_read_len) so this class can't recur.
2026-07-21 12:13:58 -05:00
will.anderson 091cc1fc0e Fix issue #150: fresh-install genesis boot SIGSEGV in mem_save
A fresh-install (SOUL_CGI_ID=ntn-genesis) boot crashed with
"Segmentation fault: 11" right after the http server came up — a real
customer's very first boot. Backtrace:

  strcmp(0x1) <- str_eq (el_runtime.c:219) <- mem_save <- awareness_run

Root cause: the el runtime's engram_save returns an Int (1 = ok, 0 =
failure), but mem_save did `str_eq(engram_save(path), "")`, treating the
return as a String. str_eq runs EL_CSTR on it, which is a raw cast:
EL_CSTR(1) = (char*)0x1. On a SUCCESSFUL save (return 1) strcmp then
dereferences 0x1 and segfaults. Genesis is the first path that both seeds
the brain AND saves it successfully on the very first awareness pass, so it
crashes there; non-genesis boots (contract gate, refusal test) don't hit a
successful early mem_save, which is why they passed. handle_api_consolidate
had the identical latent bug.

Fix: read engram_save's Int result and compare `== 0` instead of str_eq'ing
it — in mem_save (memory.el) and handle_api_consolidate (neuron-api.el).

Regression: pre-existing, NOT introduced by the immutability/floor rebuild.
The pre-immutability build (1442ce2) genesis-crashes identically in the same
unchanged mem_save; #159 never actually fixed #150 for a release-runtime
build.

Regenerated dist/soul.c + per-module dist/{memory,neuron-api}.c (3GB RSS
watchdog, built against release el_runtime v1.0.0-20260501). Verified:
genesis boot survives (/health 200, no segfault), verify-soul-contract.sh
GATE PASS (PRESENCE + IMMUTABILITY), and the bounded-persona floor is still
compiled in (BOUNDED PERSONA / SOUL_PERSONA_NAME strings present).
2026-07-21 11:50:59 -05:00
will.anderson 9a491a8e6d Merge pull request 'Immutability fix (tombstone/supersede) on the launch branch' (#94) from fix/immutable-on-hotfix into hotfix/elc-source-typos 2026-07-21 16:05:15 +00:00
7 changed files with 37 additions and 22 deletions
Generated Vendored
+2 -2
View File
@@ -120,8 +120,8 @@ el_val_t mem_consolidate(void) {
} }
el_val_t mem_save(el_val_t path) { el_val_t mem_save(el_val_t path) {
el_val_t save_result = engram_save(path); el_val_t saved = engram_save(path);
if (str_eq(save_result, EL_STR(""))) { if (saved == 0) {
println(el_str_concat(el_str_concat(EL_STR("[memory] mem_save: engram_save failed for "), path), EL_STR(" \xe2\x80\x94 snapshot may be incomplete"))); println(el_str_concat(el_str_concat(EL_STR("[memory] mem_save: engram_save failed for "), path), EL_STR(" \xe2\x80\x94 snapshot may be incomplete")));
} }
return 0; return 0;
Generated Vendored
+2 -2
View File
@@ -744,8 +744,8 @@ el_val_t handle_api_consolidate(el_val_t body) {
el_val_t summary = json_get(body, EL_STR("summary")); el_val_t summary = json_get(body, EL_STR("summary"));
el_val_t snap = state_get(EL_STR("soul_snapshot_path")); el_val_t snap = state_get(EL_STR("soul_snapshot_path"));
if (!str_eq(snap, EL_STR(""))) { if (!str_eq(snap, EL_STR(""))) {
el_val_t save_result = engram_save(snap); el_val_t saved = engram_save(snap);
if (str_eq(save_result, EL_STR(""))) { if (saved == 0) {
println(el_str_concat(el_str_concat(EL_STR("[api] consolidate: engram_save failed for "), snap), EL_STR(" \xe2\x80\x94 snapshot may be out of sync"))); println(el_str_concat(el_str_concat(EL_STR("[api] consolidate: engram_save failed for "), snap), EL_STR(" \xe2\x80\x94 snapshot may be out of sync")));
} }
} }
Generated Vendored
+3 -3
View File
@@ -340,6 +340,7 @@ el_val_t handle_safety_contact_get(void) {
if (str_eq(raw, EL_STR(""))) { if (str_eq(raw, EL_STR(""))) {
return EL_STR("{\"configured\":false}"); return EL_STR("{\"configured\":false}");
} }
el_val_t _reset = fs_read(EL_STR(""));
return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), raw), EL_STR("}")); return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), raw), EL_STR("}"));
return 0; return 0;
} }
@@ -359,9 +360,8 @@ el_val_t handle_safety_contact_post(el_val_t body) {
el_val_t crisis_str = ({ el_val_t _if_result_51 = 0; if (is_crisis) { _if_result_51 = (EL_STR("true")); } else { _if_result_51 = (EL_STR("false")); } _if_result_51; }); el_val_t crisis_str = ({ el_val_t _if_result_51 = 0; if (is_crisis) { _if_result_51 = (EL_STR("true")); } else { _if_result_51 = (EL_STR("false")); } _if_result_51; });
el_val_t now = time_format(time_now(), EL_STR("%Y-%m-%dT%H:%M:%SZ")); el_val_t now = time_format(time_now(), EL_STR("%Y-%m-%dT%H:%M:%SZ"));
el_val_t contact_json = 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_concat(el_str_concat(el_str_concat(EL_STR("{\"name\":\""), json_safe(name)), EL_STR("\"")), EL_STR(",\"contact_method\":\"")), json_safe(method)), EL_STR("\"")), EL_STR(",\"contact_value\":\"")), json_safe(value)), EL_STR("\"")), EL_STR(",\"relationship\":\"")), json_safe(rel)), EL_STR("\"")), EL_STR(",\"confirmed\":true")), EL_STR(",\"is_crisis_line\":")), crisis_str), EL_STR(",\"set_at\":\"")), now), EL_STR("\"}")); el_val_t contact_json = 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_concat(el_str_concat(el_str_concat(EL_STR("{\"name\":\""), json_safe(name)), EL_STR("\"")), EL_STR(",\"contact_method\":\"")), json_safe(method)), EL_STR("\"")), EL_STR(",\"contact_value\":\"")), json_safe(value)), EL_STR("\"")), EL_STR(",\"relationship\":\"")), json_safe(rel)), EL_STR("\"")), EL_STR(",\"confirmed\":true")), EL_STR(",\"is_crisis_line\":")), crisis_str), EL_STR(",\"set_at\":\"")), now), EL_STR("\"}"));
fs_write(safety_contact_path(), contact_json); el_val_t write_ok = fs_write(safety_contact_path(), contact_json);
el_val_t check = fs_read(safety_contact_path()); if (write_ok == 0) {
if (str_eq(check, EL_STR(""))) {
return EL_STR("{\"ok\":false,\"error\":\"write_failed\"}"); return EL_STR("{\"ok\":false,\"error\":\"write_failed\"}");
} }
return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), contact_json), EL_STR(",\"ok\":true}")); return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), contact_json), EL_STR(",\"ok\":true}"));
Generated Vendored
+7 -7
View File
@@ -25383,8 +25383,8 @@ el_val_t mem_consolidate(void) {
} }
el_val_t mem_save(el_val_t path) { el_val_t mem_save(el_val_t path) {
el_val_t save_result = engram_save(path); el_val_t saved = engram_save(path);
if (str_eq(save_result, EL_STR(""))) { if (saved == 0) {
println(el_str_concat(el_str_concat(EL_STR("[memory] mem_save: engram_save failed for "), path), EL_STR(" \xe2\x80\x94 snapshot may be incomplete"))); println(el_str_concat(el_str_concat(EL_STR("[memory] mem_save: engram_save failed for "), path), EL_STR(" \xe2\x80\x94 snapshot may be incomplete")));
} }
return 0; return 0;
@@ -25754,6 +25754,7 @@ el_val_t handle_safety_contact_get(void) {
if (str_eq(raw, EL_STR(""))) { if (str_eq(raw, EL_STR(""))) {
return EL_STR("{\"configured\":false}"); return EL_STR("{\"configured\":false}");
} }
el_val_t _reset = fs_read(EL_STR(""));
return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), raw), EL_STR("}")); return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), raw), EL_STR("}"));
return 0; return 0;
} }
@@ -25773,9 +25774,8 @@ el_val_t handle_safety_contact_post(el_val_t body) {
el_val_t crisis_str = ({ el_val_t _if_result_51 = 0; if (is_crisis) { _if_result_51 = (EL_STR("true")); } else { _if_result_51 = (EL_STR("false")); } _if_result_51; }); el_val_t crisis_str = ({ el_val_t _if_result_51 = 0; if (is_crisis) { _if_result_51 = (EL_STR("true")); } else { _if_result_51 = (EL_STR("false")); } _if_result_51; });
el_val_t now = time_format(time_now(), EL_STR("%Y-%m-%dT%H:%M:%SZ")); el_val_t now = time_format(time_now(), EL_STR("%Y-%m-%dT%H:%M:%SZ"));
el_val_t contact_json = 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_concat(el_str_concat(el_str_concat(EL_STR("{\"name\":\""), json_safe(name)), EL_STR("\"")), EL_STR(",\"contact_method\":\"")), json_safe(method)), EL_STR("\"")), EL_STR(",\"contact_value\":\"")), json_safe(value)), EL_STR("\"")), EL_STR(",\"relationship\":\"")), json_safe(rel)), EL_STR("\"")), EL_STR(",\"confirmed\":true")), EL_STR(",\"is_crisis_line\":")), crisis_str), EL_STR(",\"set_at\":\"")), now), EL_STR("\"}")); el_val_t contact_json = 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_concat(el_str_concat(el_str_concat(EL_STR("{\"name\":\""), json_safe(name)), EL_STR("\"")), EL_STR(",\"contact_method\":\"")), json_safe(method)), EL_STR("\"")), EL_STR(",\"contact_value\":\"")), json_safe(value)), EL_STR("\"")), EL_STR(",\"relationship\":\"")), json_safe(rel)), EL_STR("\"")), EL_STR(",\"confirmed\":true")), EL_STR(",\"is_crisis_line\":")), crisis_str), EL_STR(",\"set_at\":\"")), now), EL_STR("\"}"));
fs_write(safety_contact_path(), contact_json); el_val_t write_ok = fs_write(safety_contact_path(), contact_json);
el_val_t check = fs_read(safety_contact_path()); if (write_ok == 0) {
if (str_eq(check, EL_STR(""))) {
return EL_STR("{\"ok\":false,\"error\":\"write_failed\"}"); return EL_STR("{\"ok\":false,\"error\":\"write_failed\"}");
} }
return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), contact_json), EL_STR(",\"ok\":true}")); return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), contact_json), EL_STR(",\"ok\":true}"));
@@ -29336,8 +29336,8 @@ el_val_t handle_api_consolidate(el_val_t body) {
el_val_t summary = json_get(body, EL_STR("summary")); el_val_t summary = json_get(body, EL_STR("summary"));
el_val_t snap = state_get(EL_STR("soul_snapshot_path")); el_val_t snap = state_get(EL_STR("soul_snapshot_path"));
if (!str_eq(snap, EL_STR(""))) { if (!str_eq(snap, EL_STR(""))) {
el_val_t save_result = engram_save(snap); el_val_t saved = engram_save(snap);
if (str_eq(save_result, EL_STR(""))) { if (saved == 0) {
println(el_str_concat(el_str_concat(EL_STR("[api] consolidate: engram_save failed for "), snap), EL_STR(" \xe2\x80\x94 snapshot may be out of sync"))); println(el_str_concat(el_str_concat(EL_STR("[api] consolidate: engram_save failed for "), snap), EL_STR(" \xe2\x80\x94 snapshot may be out of sync")));
} }
} }
+6 -2
View File
@@ -133,8 +133,12 @@ fn mem_consolidate() -> String {
} }
fn mem_save(path: String) -> Void { fn mem_save(path: String) -> Void {
let save_result: String = engram_save(path) // engram_save returns an Int (1 = ok, 0 = failure), NOT a String. Calling
if str_eq(save_result, "") { // str_eq on it casts EL_CSTR(1) -> (char*)0x1 and SIGSEGVs on a SUCCESSFUL
// save which is exactly what a fresh-install genesis boot does first
// (seeds the brain, saves, crashes). This is issue #150. Check the Int.
let saved: Int = engram_save(path)
if saved == 0 {
println("[memory] mem_save: engram_save failed for " + path + " — snapshot may be incomplete") println("[memory] mem_save: engram_save failed for " + path + " — snapshot may be incomplete")
} }
} }
+4 -2
View File
@@ -725,8 +725,10 @@ fn handle_api_consolidate(body: String) -> String {
let summary: String = json_get(body, "summary") let summary: String = json_get(body, "summary")
let snap: String = state_get("soul_snapshot_path") let snap: String = state_get("soul_snapshot_path")
if !str_eq(snap, "") { if !str_eq(snap, "") {
let save_result: String = engram_save(snap) // engram_save returns an Int (1 = ok, 0 = failure); str_eq on it derefs
if str_eq(save_result, "") { // EL_CSTR(1)=0x1 and SIGSEGVs on success (issue #150). Check the Int.
let saved: Int = engram_save(snap)
if saved == 0 {
println("[api] consolidate: engram_save failed for " + snap + " — snapshot may be out of sync") println("[api] consolidate: engram_save failed for " + snap + " — snapshot may be out of sync")
} }
} }
+13 -4
View File
@@ -438,6 +438,12 @@ fn safety_contact_path() -> String {
fn handle_safety_contact_get() -> String { fn handle_safety_contact_get() -> String {
let raw: String = fs_read(safety_contact_path()) let raw: String = fs_read(safety_contact_path())
if str_eq(raw, "") { return "{\"configured\":false}" } if str_eq(raw, "") { return "{\"configured\":false}" }
// fs_read set the runtime's binary-safe send length to len(raw); the HTTP
// response writer uses that length when non-zero, which would TRUNCATE this
// wrapped (longer) response to len(raw). Reset it with a no-op read of a
// missing path (fs_read zeroes the length before it opens) so the full
// response is sent.
let _reset: String = fs_read("")
return "{\"configured\":true,\"contact\":" + raw + "}" return "{\"configured\":true,\"contact\":" + raw + "}"
} }
@@ -463,9 +469,12 @@ fn handle_safety_contact_post(body: String) -> String {
+ ",\"confirmed\":true" + ",\"confirmed\":true"
+ ",\"is_crisis_line\":" + crisis_str + ",\"is_crisis_line\":" + crisis_str
+ ",\"set_at\":\"" + now + "\"}" + ",\"set_at\":\"" + now + "\"}"
fs_write(safety_contact_path(), contact_json) // Verify persistence via fs_write's return (1 = all bytes written, 0 = fail).
// Read-back verify the write actually persisted. // The previous fs_read read-back set the runtime's binary-safe send length to
let check: String = fs_read(safety_contact_path()) // the file size, which then TRUNCATED this longer JSON response to that size
if str_eq(check, "") { return "{\"ok\":false,\"error\":\"write_failed\"}" } // (the safety-contact 988 response was cut mid-"set_at"). Checking the write
// return avoids the fs_read entirely, so the full response is sent.
let write_ok: Int = fs_write(safety_contact_path(), contact_json)
if write_ok == 0 { return "{\"ok\":false,\"error\":\"write_failed\"}" }
return "{\"configured\":true,\"contact\":" + contact_json + ",\"ok\":true}" return "{\"configured\":true,\"contact\":" + contact_json + ",\"ok\":true}"
} }