Compare commits

...

7 Commits

Author SHA1 Message Date
Neuron 5743568bf1 chore(engine): deploys build from the committed input, not a second lineage
Until now deploys were built by build-soul.sh from a scratch amalgam while CI
compiled the committed dist/soul.c. Two lineages — 'what runs' and 'what the repo
says builds' were different artifacts. That is #133/#111 in another costume, and it
is how a round-9.1 brain shipped matching no committed source at all.

build-soul-from-dist.sh asserts dist/soul.c matches the .el sources, compiles it
with CI's own flags (-O2 -DHAVE_CURL -rdynamic; the CI comment explains -rdynamic —
without it the runtime cannot resolve its HTTP handler by name and the binary serves
nothing on every route), and writes a .provenance sidecar recording the dist/soul.c
hash, the stamp hash and the commit.

deploy_binary.sh gains GATE 0: refuse any soul without provenance, or built from a
dist/soul.c other than the one in the repo now. The override
(NEURON_DEPLOY_UNSTAMPED=i-accept-two-lineages) exists deliberately — a gate with no
escape hatch gets bypassed by disabling the gate, which is worse than one that
announces itself.

Verified before enforcing, so the sanctioned path is not a broken one:
  interface parity with the deployed binary   108 routes in, 108 out
  boots and serves                            2s
  carries today's work                        24 self-neighbours, 17 identity records
  GATE 0 refuses an unstamped binary          exit 10
  GATE 0 accepts the stamped one, deployed    durability PASS

Production now runs 4845db3d, built from dist/soul.c at 9fd8c11. After the swap:
identity in context 17, connections still form on write, semantic recall intact,
mind and store at delta 0.

Refs #133, #111

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:38:01 -05:00
Neuron 9fd8c11670 chore(engine): make dist/soul.c drift a build failure instead of a silent ship (#133)
#133 regenerated the amalgam once and said so itself: 'Nothing in the tree
regenerates this file. Only a human running the recipe. It lags in batches, never
per-change, and it will drift again.'

It drifted again. Every binary deployed on 2026-08-09 was built by build-soul.sh
from a scratch amalgam that never touches dist/soul.c, so the committed build
input fell 2,761 bytes behind the sources by a different route than #133 describes.

Auto-regeneration is not available: the CI workflow records that elc needs 24GB+
of virtual memory and would OOM the runner. So the build cannot regenerate the
file. It can refuse to compile a stale one, for free and with no compiler.

tools/soulc-stamp.sh records a content fingerprint of every .el source at the
moment the amalgam is generated. --check recomputes and compares; divergence exits
1 and names the changed files and the recipe. Wired into CI ahead of the compile.

dist/soul.c regenerated from current sources: 1,176,361 -> 1,179,122 bytes, 1,247
inlined bodies (gate wants >=1200), and verified to compile clean at 903,552 bytes.

Demonstrated to FAIL on the bad input, per postmortem 0004's rule that a gate which
only passes on good input proves nothing:
  fresh stamp        -> OK,   exit 0
  one .el modified   -> FAIL, exit 1, names memory.el
  source restored    -> OK,   exit 0

Refs #133, #111

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:32:56 -05:00
Neuron be0f9d1afe feat(soul): memories form connections when written
The design rejects what this system did: promotion must link candidates 'using
typed semantic edges rather than appending as unlinked content' (CCR claim 29).
Unlinked append was the only behaviour we had. Measured 2026-08-09: 14,214 edges
over 80,936 nodes, 5% of nodes connected to anything, and no edge created by any
write since 2026-07-19 across 27,000+ new nodes. A memory with no connections is
unreachable by spreading activation, so retrieval degrades to literal matching.

On write, a memory is now linked to its top related existing memories.

Bounds, each bought with a specific failure:
  - max 3 edges per memory (link_memories.py's cap: precision over spray)
  - never link to identity. The existing policy is explicit that 'memories must
    not pollute the self traversal by similarity; only an explicit citation may
    touch identity'.
  - never link telemetry (state-event, soul-response, boot_count, loop-outcome,
    search-result): ~97% of daily write volume. Linking it would add thousands of
    noise edges a day and re-flatten the graph in the name of connecting it.
  - fail-soft: a failed association never fails the write
  - associate only AFTER durability, so no edge points at a node that did not
    persist — that is the dangling-edge defect the 08-09 cleanup removed 830 of

Two defects found by measuring rather than reading, both fixed here:
  1. Hooking mem_store alone produced ZERO edges across four real writes. The HTTP
     memory route writes via wt_node directly; mem_store serves only the awareness
     telemetry paths we refuse to link.
  2. A lowercase-only identity check let a memory link to 'Self — Values
     (grounded)' — the exact pollution the policy forbids. My verification shared
     the blind spot and printed PASS. Now uses str_lower.

Measured, lab, same corpus: ~1-2 edges per memory; identity edges unchanged at
475; zero identity leaks post-fix including an adversarial batch of six memories
written about values. Edges route through wt_edge so they reach the owner.

Rung: E2E-VERIFIED in an isolated lab. Not deployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:00:04 -05:00
Neuron 1742d0b575 feat(soul): seed compiled context from the self node, bounded
The design is explicit — 'Every compilation query begins at the self-model node
and traverses outward... structural reachability from the self-model node is a
precondition for any node to appear in compiled context.' (will-anderson
patents/drafts/engram-claims.md, Self-Seeded Activation. DRAFT, not a filed
provisional — cited as such.)

Measured before: compiled context held 0-1 identity records out of 10, because
compilation seeds from a hardcoded text string and never from the self. Even an
explicit 'my values identity who I am' query returned a boot counter and
state-events.

This restores the designed behaviour without repeating the failure that set
self_neighbors to [] originally: that was an unbounded ~90KB neighbour dump which
closed the socket on every call. Same bound as every other list in the handler.

Cap is 24 rather than 8 because the self root's first 8 neighbours are tag nodes
('neuron', 'tier:note', 'disposition:experimental', 'imprint', 'traversal') that
crowd out the substantive identity records behind them. The root has 34
neighbours of which 23 are identity.

Measured after, same corpus, binary as the only variable:
  identity records in compiled context  0 -> 17
  response size                         10,806 -> 18,985 bytes
  interface gate                        108 routes in, 108 out, none removed

Rung: E2E-VERIFIED in an isolated lab. Not deployed — this is the identity layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:45:12 -05:00
Tim Lingo c6772e3d27 Merge branch 'feat/semantic-leg-dropout' into integrate/semantic-plus-writethrough
Neuron Soul CI / build (pull_request) Failing after 11m47s
Neuron Soul CI / deploy (pull_request) Failing after 14m48s
2026-08-09 09:47:44 -05:00
tim.lingo 9501e4ac12 Merge pull request 'fix(engine): memories written through the soul now reach the store that owns them (closes #117)' (#134) from feat/soul-write-through into main
Neuron Soul CI / build (push) Failing after 11m27s
Neuron Soul CI / deploy (push) Failing after 14m46s
2026-08-07 21:07:14 +00:00
Tim Lingo dd952c0e46 feat(soul): write-through to the persistence owner — memories survive restart (#117)
Neuron Soul CI / build (pull_request) Failing after 14m57s
Neuron Soul CI / deploy (pull_request) Failing after 14m39s
The soul obeys half of its own ownership rule. soul.el:571-573 says "when
ENGRAM_URL is set the HTTP Engram owns persistence — the soul must NEVER write
to the local snapshot", and it doesn't. But nothing was ever built to hand the
soul's writes TO that owner: sync is pull-only (/api/sync -> engram_load_merge),
so every node created inside the soul lived in process RAM and was shed on
restart. Measured live 2026-08-07: soul node_count=102184, engram 79197.

SCOPE CORRECTION vs the earlier internal spec: engram provisional claim 17's
"pull-then-push" is a PEER-ENGRAM to PEER-ENGRAM protocol (claims 15-18 say so
explicitly). The soul is a CALLER of the database API, not a peer. Claim 17 is
NOT authority for a soul<->engram contract and is no longer cited as such. The
design here follows from the ownership rule alone.

Mechanism: a new Accessor, persist.el, is the single boundary. Writes stage a
delta to a filesystem spool and are pushed to the owner via POST /api/load-merge
— NOT POST /api/nodes, which mints a new server-side id (breaking dedup and
edges) and drops label/tier/tags/importance/confidence (verified in a sandbox:
a tier "Canonical" probe came back "Working"). load-merge preserves the id and
every field, dedups nodes by id and edges by (from,to,relation) so retries are
no-ops, and calls persist_canonical() so THE OWNER writes its own file — the
ownership rule is honoured rather than worked around.

Spool-and-drain rather than push-per-write: measured ~0.38s per load-merge at
live scale (79k nodes/176MB), and a chat turn writes 5-7 nodes. The spool is on
disk, not in process state, because the soul serves each connection on its own
pthread and a shared buffer would lose entries to a read-modify-write race. That
also buys crash recovery: writes orphaned by kill -9 are drained on next boot.

Honesty: api_persisted (the gate all 10 MCP write handlers pass through) and
mem_store now assert AT THE OWNER instead of reading back the soul's own RAM.
With the owner down a write returns {"ok":false,"error":"write_not_persisted"}
and the delta is queued — where main returns {"ok":true} for a write that dies.

Coverage: 35 node sites + 9 edge sites routed through the boundary. Deliberately
excluded, with reasons in persist.el: 4 InternalStateEvent sites (Will's own
telemetry carve-out), the boot counter and the persona (both already have
bespoke owner-side write-backs), and soul.el's 54 genesis identity edges
(file-mode only). engram_strengthen and engram_forget are NOT propagated —
load-merge cannot update or delete, and hard-deleting at the owner would fail
verify-soul-contract.sh section B.

Also fixed here:
- routes.el GET /api/graph/edges engram_save()'d straight over the owner's
  canonical snapshot.json — a read route, in a non-owner process, clobbering the
  canonical on every call. Same defect class Will removed from the engram in el
  dc39a61. Now exports to a scratch path. With this gone the soul writes nothing
  at all in HTTP mode.
- persist.el must clear the runtime's _tl_fs_read_len hint after every fs_read.
  In vendored runtime v1.0.0-20260501 that hint becomes the NEXT response's
  Content-Length, so reading a spool file mid-request made an 86-byte reply go
  out as 497 bytes with 411 bytes of adjacent heap trailing it. Caught and fixed
  at our boundary; the runtime class was fixed upstream in el 43636ae, which is
  not the pinned runtime here.

Rung: E2E-VERIFIED, discriminating. Same harness, same engram binary:
  write-through: LEG 1 PRESENT at owner, LEG 2 SURVIVED kill -9 + restart
  main:          LEG 1 ABSENT  at owner, LEG 2 LOST
verify-soul-contract.sh: GATE PASS on both builds (27/27 routes, immutability).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:47:31 -05:00
15 changed files with 2252 additions and 726 deletions
+10
View File
@@ -63,6 +63,16 @@ jobs:
cp vendor/el-runtime/v1.0.0-20260501/el_runtime.h /opt/el/runtime/el_runtime.h cp vendor/el-runtime/v1.0.0-20260501/el_runtime.h /opt/el/runtime/el_runtime.h
echo "El runtime PINNED to v1.0.0-20260501: $(ls /opt/el/runtime/)" echo "El runtime PINNED to v1.0.0-20260501: $(ls /opt/el/runtime/)"
# neuron#133: CI compiles dist/soul.c, NOT the .el sources. On 2026-08-07 a
# build off main would have shipped an engine with none of five merged fixes,
# including a P0 safety fix, while main's source read as correct. The runner
# cannot regenerate the amalgam (elc needs 24GB+ virtual memory), but it can
# refuse to compile a stale one. Fails loudly with the recipe in the message.
- name: Verify dist/soul.c matches the sources
run: |
chmod +x tools/soulc-stamp.sh
./tools/soulc-stamp.sh --check
- name: Build neuron soul binary - name: Build neuron soul binary
run: | run: |
RUNTIME=/opt/el/runtime RUNTIME=/opt/el/runtime
+19
View File
@@ -889,10 +889,29 @@ fn awareness_run() -> Void {
state_set("soul.last_beat_ts", int_to_str(now_ts)) state_set("soul.last_beat_ts", int_to_str(now_ts))
// Persist in-process Engram (sessions, memories, conversation nodes) // Persist in-process Engram (sessions, memories, conversation nodes)
// to local snapshot so they survive restarts. // to local snapshot so they survive restarts.
// FILE MODE ONLY: "soul_snapshot_path" is set exclusively in the
// genesis+safe_to_seed branch of soul.el, and safe_to_seed is
// unconditionally false when ENGRAM_URL is set. In HTTP mode the
// owner persists; the soul must not (soul.el:571-573).
let snap_path: String = state_get("soul_snapshot_path") let snap_path: String = state_get("soul_snapshot_path")
if !str_eq(snap_path, "") { if !str_eq(snap_path, "") {
mem_save(snap_path) mem_save(snap_path)
} }
// WRITE-THROUGH RETRY (neuron#117). The HTTP-mode counterpart of the
// save above: hand anything still spooled to the persistence owner.
//
// This is the retry arm of the whole design. Deltas that could not be
// pushed owner down, owner restarting, transient refusal stay on
// disk and are re-offered here every heartbeat until they land. It is
// also the catch-all for writes made by the awareness loop itself,
// which never passes through the HTTP handler's flush point.
//
// No-op with no HTTP call when the spool is empty or ENGRAM_URL is
// unset, so an idle soul in file mode pays nothing for this.
let wt_pushed: Int = wt_drain()
if wt_pushed < 0 {
ise_post("{\"event\":\"write_through_backlog\",\"ts\":" + int_to_str(now_ts) + "}")
}
} }
// Curiosity scan: idle-gated AND wall-clock based. Only fires when the // Curiosity scan: idle-gated AND wall-clock based. Only fires when the
+8 -8
View File
@@ -1162,7 +1162,7 @@ fn hist_trim_with_bell_guard(hist: String) -> String {
+ " | evicted_at:" + ts_str + " | evicted_at:" + ts_str
+ " | message:" + safe_content + " | message:" + safe_content
let preserve_tags: String = "[\"bell-history\",\"bell:" + bell_level + "\",\"evicted\",\"affective\",\"BellEvent\"]" let preserve_tags: String = "[\"bell-history\",\"bell:" + bell_level + "\",\"evicted\",\"affective\",\"BellEvent\"]"
let discard: String = engram_node_full( let discard: String = wt_node(
preserve_content, preserve_content,
"BellEvent", "BellEvent",
"bell:" + bell_level + ":preserved", "bell:" + bell_level + ":preserved",
@@ -1210,7 +1210,7 @@ fn conv_history_persist(session_id: String, hist: String) -> Void {
if !str_contains(hist, "]") { return "" } if !str_contains(hist, "]") { return "" }
let tags: String = "[\"conv-history\",\"persistent\"]" let tags: String = "[\"conv-history\",\"persistent\"]"
// FIX B: one label rule, shared with the agentic path. See conv_hist_label. // FIX B: one label rule, shared with the agentic path. See conv_hist_label.
let node_id: String = engram_node_full( let node_id: String = wt_node(
hist, "Conversation", conv_hist_label(session_id), hist, "Conversation", conv_hist_label(session_id),
el_from_float(0.7), el_from_float(0.8), el_from_float(0.9), el_from_float(0.7), el_from_float(0.8), el_from_float(0.9),
"Episodic", tags "Episodic", tags
@@ -3461,7 +3461,7 @@ fn handle_dharma_room_turn(body: String) -> String {
// engram_node(content, "episodic", ...) which wrongly put a TIER into the node_type // engram_node(content, "episodic", ...) which wrongly put a TIER into the node_type
// slot that's why nodes showed node_type="episodic". Use the full, correct contract.) // slot that's why nodes showed node_type="episodic". Use the full, correct contract.)
let utterance_tags: String = "[\"soul-utterance\",\"episodic\"]" let utterance_tags: String = "[\"soul-utterance\",\"episodic\"]"
let discard_id: String = engram_node_full( let discard_id: String = wt_node(
clean_response, "Conversation", "soul:utterance", clean_response, "Conversation", "soul:utterance",
el_from_float(0.6), el_from_float(0.6), el_from_float(0.8), el_from_float(0.6), el_from_float(0.6), el_from_float(0.8),
"Episodic", utterance_tags "Episodic", utterance_tags
@@ -3552,7 +3552,7 @@ fn session_summary_write(summary_text: String) -> String {
} }
} }
let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]" let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]"
let node_id: String = engram_node_full( let node_id: String = wt_node(
content, "SessionSummary", "session:summary", content, "SessionSummary", "session:summary",
el_from_float(0.85), el_from_float(0.85), el_from_float(1.0), el_from_float(0.85), el_from_float(0.85), el_from_float(1.0),
"Episodic", tags "Episodic", tags
@@ -3578,7 +3578,7 @@ fn session_summary_write_dated(summary_text: String, label: String) -> String {
let ts_str: String = int_to_str(ts) let ts_str: String = int_to_str(ts)
let content: String = "[session-summary] " + trimmed + " | ts:" + ts_str let content: String = "[session-summary] " + trimmed + " | ts:" + ts_str
let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]" let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]"
let node_id: String = engram_node_full( let node_id: String = wt_node(
content, "SessionSummary", label, content, "SessionSummary", label,
el_from_float(0.9), el_from_float(0.8), el_from_float(1.0), el_from_float(0.9), el_from_float(0.8), el_from_float(1.0),
"Episodic", tags "Episodic", tags
@@ -3654,7 +3654,7 @@ fn auto_persist(req: String, resp: String) -> Void {
+ ",\"bell\":\"" + bell_level + "\"" + ",\"bell\":\"" + bell_level + "\""
+ ",\"label\":\"chat:" + ts_str + "\"}" + ",\"label\":\"chat:" + ts_str + "\"}"
let conv_node_id: String = engram_node_full( let conv_node_id: String = wt_node(
content, content,
"Conversation", "Conversation",
"chat:" + ts_str, "chat:" + ts_str,
@@ -3692,7 +3692,7 @@ fn auto_persist(req: String, resp: String) -> Void {
let bell_tags: String = "[\"safety\",\"bell\",\"bell:" + bell_level + "\",\"affective\",\"BellEvent\"]" let bell_tags: String = "[\"safety\",\"bell\",\"bell:" + bell_level + "\",\"affective\",\"BellEvent\"]"
let bell_ts_str: String = int_to_str(time_now()) let bell_ts_str: String = int_to_str(time_now())
let bell_label: String = "bell:" + bell_level + ":" + bell_ts_str let bell_label: String = "bell:" + bell_level + ":" + bell_ts_str
let bell_node_id: String = engram_node_full( let bell_node_id: String = wt_node(
bell_content, bell_content,
"BellEvent", "BellEvent",
bell_label, bell_label,
@@ -3751,7 +3751,7 @@ fn auto_persist(req: String, resp: String) -> Void {
let pos_tags: String = "[\"joy\",\"positive\",\"joy:" + positive_level + "\",\"affective\",\"PositiveEvent\"]" let pos_tags: String = "[\"joy\",\"positive\",\"joy:" + positive_level + "\",\"affective\",\"PositiveEvent\"]"
let pos_ts_label: String = int_to_str(time_now()) let pos_ts_label: String = int_to_str(time_now())
let pos_label: String = "joy:" + positive_level + ":" + pos_ts_label let pos_label: String = "joy:" + positive_level + ":" + pos_ts_label
let pos_node_id: String = engram_node_full( let pos_node_id: String = wt_node(
pos_content, "PositiveEvent", pos_label, pos_content, "PositiveEvent", pos_label,
pos_sal_a, pos_sal_b, pos_sal_c, "Episodic", pos_tags pos_sal_a, pos_sal_b, pos_sal_c, "Episodic", pos_tags
) )
Generated Vendored
+1384 -664
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+18
View File
@@ -0,0 +1,18 @@
# soul.c.stamp — fingerprint of the .el sources dist/soul.c was generated from.
# Written by tools/soulc-stamp.sh --write. Do not hand-edit.
# generated_amalgam_sha256 e31f760de5f6629cd37bcfd26172f92be3ca5d6ff36480f6adafa1d4a1de5a66
# generated_amalgam_bytes 1179122
f8597e10546654bce3fbbe40461b2da59d0e06dbf1b038d1d362d24f949e3911 awareness.el
b6f3d14ca0c26017a2d617399a6d3754dabb0905e4d5f52eb75d25c4ad18d3c5 chat.el
42288c212cbf72fb1e8ecbd4d9900e4e9ee1cfa475b7974295c7637f1bf2939f elp-input.el
b3f77f49d6086932c38bd17fe7a5eaf8bce25685f6fc3e1750f05729c6b49b9e imprint.el
fba8ffdb9ba72bca5b09ca1c93a520edc52f3f4d8aec2c7585fe9b17e06420b2 manifest.el
550a72e234ae8cec1f33e02108fd365353f45edd88513da90b792e79b6c0e5f0 memory.el
77640783df5c38066149dcd11a11aea7e36f66e40e3df6aedf6723be8b9e0e1e neuron-api.el
03c47c451e0e87f2c252cadb4b765867943962a804f548dd53adeef0520912c8 persist.el
541b9309c59c33ee619d393714871b820ec4151b721d8b2aebcfdb7ce8eb2ffa routes.el
c28e36952ec56525963a0bdf29455ab097d3b0c5653d19c25fbb005e1069a1f7 safety.el
fd3ab91d0ae0ea26639e21bef2f8f94054dc4b02eae68b19e3fe689d2769aad4 sessions.el
0f1cf43904a98a5a646cce5a07e0e96162ced662692fbc13357d9b67d9a8ac3d soul.el
30337940905171a9645b0929f0a412ce6b3dccb1246495070c553bca0bbae6cd stewardship.el
e105dc5990e6adbf39db9dc0462cd8bcf6e6c3dfd03709059227ecfad2bbab29 studio.el
+104 -9
View File
@@ -1,9 +1,91 @@
import "persist.el"
fn tier_working() -> String { return "Working" } fn tier_working() -> String { return "Working" }
fn tier_episodic() -> String { return "Episodic" } fn tier_episodic() -> String { return "Episodic" }
fn tier_canonical() -> String { return "Canonical" } fn tier_canonical() -> String { return "Canonical" }
// Association on write
// DESIGN: "promotion integrates candidate nodes by linking them to existing nodes
// using typed semantic edges RATHER THAN APPENDING AS UNLINKED CONTENT" (CCR
// claim 29). Unlinked append is the explicitly rejected behaviour and it is the
// only behaviour this system had. Measured 2026-08-09 on Tim's graph: 14,214 edges
// across 80,936 nodes, 5% of nodes connected to anything, and NO edge created by
// any write since 2026-07-19 while 27,000+ nodes were added. A memory that forms
// no connections cannot be reached by spreading activation, so retrieval silently
// degrades to literal matching.
//
// BOUNDS, each one bought with a specific failure:
// * max 3 edges per memory link_memories.py's cap, precision over spray
// * never link to identity (self/*, Value): the existing policy is explicit that
// "memories must not pollute the self traversal by similarity; only an explicit
// citation may touch identity". Similarity is not citation.
// * never link telemetry (state-event, soul-response, boot_count, loop-outcome):
// these are ~97% of daily write volume (1,020 vs 31 real memories on 08-08).
// Linking them would add ~3,000 noise edges a day and re-flatten the graph in
// the name of connecting it.
// * fail-soft: a failed association never fails the write.
// Edges go through wt_edge so they reach the owner and survive restart.
fn mem_assoc_skip_label(label: String) -> Bool {
if str_contains(label, "state-event") { return true }
if str_contains(label, "soul-response") { return true }
if str_contains(label, "soul-outbox") { return true }
if str_contains(label, "boot_count") { return true }
if str_contains(label, "loop-outcome") { return true }
if str_contains(label, "search-result") { return true }
return false
}
// A candidate is linkable only if it is a real, distinct, non-identity node.
fn mem_assoc_ok(cand_id: String, cand_label: String, self_id: String) -> Bool {
if str_eq(cand_id, "") { return false }
if str_eq(cand_id, self_id) { return false }
// CASE MATTERS measured 2026-08-09. A lowercase-only check let a memory link
// to "Self — Values (grounded)", i.e. it polluted the self traversal, which is
// the one thing this policy exists to prevent. My verification had the same
// blind spot and printed PASS. Check every casing the graph actually uses, and
// exclude identity node TYPES as well as labels.
let lab: String = str_lower(cand_label)
if str_starts_with(lab, "self") { return false }
if str_starts_with(lab, "value") { return false }
if str_contains(lab, "values") { return false }
if str_contains(lab, "identity") { return false }
if mem_assoc_skip_label(cand_label) { return false }
return true
}
// One slot of the association. Manual unroll rather than a loop: EL's codegen
// mis-emits accumulating while-loops (documented at soul.el:212, which unrolled
// three affective slots for the same reason).
fn mem_assoc_slot(results: String, idx: Int, new_id: String) -> Void {
if idx >= json_array_len(results) { return }
let cand: String = json_array_get(results, idx)
let cid: String = json_get(cand, "id")
let clabel: String = json_get(cand, "label")
let ctype: String = json_get(cand, "node_type")
if str_eq(ctype, "Value") { return }
if str_eq(ctype, "DharmaSelf") { return }
if str_eq(ctype, "Safety") { return }
if mem_assoc_ok(cid, clabel, new_id) {
wt_edge(new_id, cid, el_from_float(0.5), "related")
}
}
// mem_associate connect a freshly written memory to what it is about.
fn mem_associate(new_id: String, content: String, label: String) -> Void {
if str_eq(new_id, "") { return }
if mem_assoc_skip_label(label) { return }
// Ask the graph what this memory resembles. Now that the store carries
// meaning-vectors this is semantic, not merely lexical.
let probe: String = str_slice(content, 0, 400)
let results: String = engram_recall_json(probe, 4)
if str_eq(results, "") { return }
mem_assoc_slot(results, 0, new_id)
mem_assoc_slot(results, 1, new_id)
mem_assoc_slot(results, 2, new_id)
}
fn mem_store(content: String, label: String, tags: String) -> String { fn mem_store(content: String, label: String, tags: String) -> String {
let id: String = engram_node_full( let id: String = wt_node(
content, content,
"Memory", "Memory",
label, label,
@@ -17,13 +99,26 @@ fn mem_store(content: String, label: String, tags: String) -> String {
println("[memory] write rejected by engram (empty id): label=" + label) println("[memory] write rejected by engram (empty id): label=" + label)
return "" return ""
} }
// Read back to verify the node actually persisted guards against silent write failures. // wt_node has already read the node back locally and returns "" if it did
let readback: String = engram_get_node_json(id) // not land, so the old duplicate read-back here is gone.
if str_eq(readback, "") || str_eq(readback, "{}") { //
println("[memory] WRITE VERIFY FAILED: label=" + label + " id=" + id + " — node absent after write") // HONESTY (neuron#117): the receipt now says WHERE the write is.
return "" // The old unconditional "write verified" line asserted against the soul's
// own RAM true in memory, false on disk and printed ~115,000 times on
// Tim's machine while the canonical snapshot sat frozen for three days.
// wt_commit flushes the spool and then asks the OWNER. When it says false
// the node is real and recallable but not yet durable, and the log says so
// rather than claiming a save that did not happen. The id is still returned:
// the local write DID succeed, and the queued delta will be retried.
let durable: Bool = wt_commit(id)
// Associate AFTER the node is durable: an edge to a node that did not persist
// is a dangling edge, which is the defect the 2026-08-09 cleanup removed 830 of.
mem_associate(id, content, label)
if durable {
println("[memory] write persisted at owner: " + id + " label=" + label)
} else {
println("[memory] write IN MEMORY ONLY (queued for owner, not yet durable): " + id + " label=" + label)
} }
println("[memory] write verified: " + id + " ok")
return id return id
} }
@@ -51,12 +146,12 @@ fn mem_strengthen(node_id: String) -> Void {
// memory.el (imported first) so awareness.el and neuron-api.el can both call it. // memory.el (imported first) so awareness.el and neuron-api.el can both call it.
fn mem_tombstone(node_id: String) -> String { fn mem_tombstone(node_id: String) -> String {
let tags: String = "[\"Tombstone\",\"status:deleted\"]" let tags: String = "[\"Tombstone\",\"status:deleted\"]"
let marker: String = engram_node_full( let marker: String = wt_node(
node_id, "Tombstone", "tombstone:" + node_id, node_id, "Tombstone", "tombstone:" + node_id,
el_from_float(0.01), el_from_float(0.01), el_from_float(1.0), el_from_float(0.01), el_from_float(0.01), el_from_float(1.0),
"Episodic", tags) "Episodic", tags)
if !str_eq(marker, "") { if !str_eq(marker, "") {
engram_connect(marker, node_id, el_from_float(1.0), "tombstones") wt_edge(marker, node_id, el_from_float(1.0), "tombstones")
} }
return marker return marker
} }
+69 -28
View File
@@ -195,15 +195,24 @@ fn api_compact_activated(raw: String, max_items: Int, snip: Int) -> String {
} }
// api_persisted read-back-after-write guard against hallucinated saves. // api_persisted read-back-after-write guard against hallucinated saves.
// After a write builtin returns an id, confirm the node is actually queryable //
// via engram_get_node_json(id) (returns "" or "null" when missing). Returns // WIDENED FOR neuron#117. This function is the single gate every MCP write
// true only when the node is genuinely persisted. // handler passes through before it reports success (10 call sites), which makes
// it the right place to close the honesty gap rather than editing ten receipts.
//
// It used to read back from engram_get_node_json the SOUL'S OWN in-process
// graph. In HTTP-engram mode that asserts the wrong thing: the soul is not the
// persistence owner, so a node present in its RAM and absent from the owner read
// as "persisted" and then vanished on the next restart. The guard was doing
// exactly what its comment promised and still certifying writes that did not
// survive. It now flushes the write-through spool and asks the OWNER.
//
// In file mode (no ENGRAM_URL) the soul IS the owner and wt_commit collapses to
// the original local read-back unchanged behaviour, which is what keeps this
// reversible.
fn api_persisted(id: String) -> Bool { fn api_persisted(id: String) -> Bool {
if str_eq(id, "") { return false } if str_eq(id, "") { return false }
let node: String = engram_get_node_json(id) return wt_commit(id)
// engram_get_node_json returns "{}" (empty object) when node is not found not "" or "null".
// Check all three to guard against any runtime variation.
return !str_eq(node, "") && !str_eq(node, "null") && !str_eq(node, "{}")
} }
// api_not_persisted standard error for a write that did not read back. // api_not_persisted standard error for a write that did not read back.
@@ -295,10 +304,35 @@ fn handle_api_begin_session(body: String) -> String {
let state_events: String = api_compact_node_array(state_events_raw, 5, 500) let state_events: String = api_compact_node_array(state_events_raw, 5, 500)
let recent_raw: String = engram_scan_nodes_json(10, 0) let recent_raw: String = engram_scan_nodes_json(10, 0)
let recent: String = api_compact_node_array(recent_raw, 10, 240) let recent: String = api_compact_node_array(recent_raw, 10, 240)
// SELF-SEEDED SLICE (2026-08-09). The design is explicit: "Every compilation
// query begins at the self-model node and traverses outward... structural
// reachability from the self-model node is a precondition for any node to
// appear in compiled context" (will-anderson patents/drafts/engram-claims.md,
// Self-Seeded Activation; DRAFT, not a filed provisional — cite it as such).
//
// Measured 2026-08-09 before this change: compiled context contained 0-1
// identity records out of 10, because compilation seeds from a hardcoded
// TEXT STRING, never from the self. Even an explicit "my values identity who
// I am" query returned a boot counter and state-events.
//
// This restores the designed behaviour WITHOUT repeating the failure that got
// self_neighbors set to [] in the first place: that was an UNBOUNDED ~90KB
// neighbour dump which closed the socket on every call. Same bound as every
// other list here cap 8, 240-char snippets. The self root has 34 direct
// neighbours of which 23 are identity records, so depth 1 is dense enough to
// be worth seeding and small enough to stay cheap.
let self_raw: String = engram_neighbors_json("kn-efeb4a5b-5aff-4759-8a97-7233099be6ee", 1, "both")
// Cap 24, not 8: measured 2026-08-09, the self root's first 8 neighbours are
// TAG nodes ("neuron", "tier:note", "disposition:experimental", "imprint",
// "traversal") which crowd out the substantive identity records behind them.
// The root has 34 neighbours of which 23 are identity; 24 captures them while
// staying bounded. Cost measured at ~+4KB on a ~12KB response, nowhere near
// the ~90KB unbounded dump that closed sockets and got this set to [].
let self_slice: String = api_compact_node_array(self_raw, 24, 240)
return "{\"stats\":" + stats return "{\"stats\":" + stats
+ ",\"recent\":" + recent + ",\"recent\":" + recent
+ ",\"activated\":" + activated + ",\"activated\":" + activated
+ ",\"self_neighbors\":[]" + ",\"self_neighbors\":" + self_slice
+ ",\"recent_state_events\":" + state_events + "}" + ",\"recent_state_events\":" + state_events + "}"
} }
@@ -342,10 +376,17 @@ fn handle_api_remember(body: String) -> String {
let inner: String = str_slice(base_tags, 1, str_len(base_tags) - 1) let inner: String = str_slice(base_tags, 1, str_len(base_tags) - 1)
"[" + inner + ",\"project:" + project + "\"]" "[" + inner + ",\"project:" + project + "\"]"
} }
let id: String = engram_node_full(content, "Memory", "memory:remembered", let id: String = wt_node(content, "Memory", "memory:remembered",
sal, sal, el_from_float(0.9), sal, sal, el_from_float(0.9),
"Episodic", final_tags) "Episodic", final_tags)
if !api_persisted(id) { return api_not_persisted(id) } if !api_persisted(id) { return api_not_persisted(id) }
// Associate on write (2026-08-09). THIS CALL MUST BE HERE, not only in mem_store.
// The HTTP memory route writes via wt_node directly; mem_store serves only the
// awareness paths (soul-response, search-result, activation-result) which are
// exactly the telemetry we refuse to link. Hooking mem_store alone produced
// ZERO edges across four real writes measured, not assumed, which is the only
// reason it was caught before shipping.
mem_associate(id, content, "memory:remembered")
return "{\"id\":\"" + id + "\",\"ok\":true}" return "{\"id\":\"" + id + "\",\"ok\":true}"
} }
@@ -369,7 +410,7 @@ fn handle_api_node_create(body: String) -> String {
if str_eq(importance, "low") { 0.25 } else { 0.5 } if str_eq(importance, "low") { 0.25 } else { 0.5 }
} }
} }
let id: String = engram_node_full(content, node_type, label, let id: String = wt_node(content, node_type, label,
sal, sal, el_from_float(0.9), sal, sal, el_from_float(0.9),
tier, tags) tier, tags)
if !api_persisted(id) { return api_not_persisted(id) } if !api_persisted(id) { return api_not_persisted(id) }
@@ -422,11 +463,11 @@ fn handle_api_node_update(body: String) -> String {
} }
let body_tags: String = json_get(body, "tags") let body_tags: String = json_get(body, "tags")
let tags: String = if str_eq(body_tags, "") { "[\"" + node_type + "\"]" } else { body_tags } let tags: String = if str_eq(body_tags, "") { "[\"" + node_type + "\"]" } else { body_tags }
let new_id: String = engram_node_full(content, node_type, label, let new_id: String = wt_node(content, node_type, label,
el_from_float(0.5), el_from_float(0.5), el_from_float(0.8), el_from_float(0.5), el_from_float(0.5), el_from_float(0.8),
tier, tags) tier, tags)
if !api_persisted(new_id) { return api_not_persisted(new_id) } if !api_persisted(new_id) { return api_not_persisted(new_id) }
engram_connect(new_id, id, el_from_float(0.9), "supersedes") wt_edge(new_id, id, el_from_float(0.9), "supersedes")
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + id + "\",\"ok\":true}" return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + id + "\",\"ok\":true}"
} }
@@ -503,7 +544,7 @@ fn handle_api_capture_knowledge(body: String) -> String {
let full: String = if str_eq(title, "") { content } else { title + ": " + content } let full: String = if str_eq(title, "") { content } else { title + ": " + content }
let lbl: String = str_slice(title, 0, 80) let lbl: String = str_slice(title, 0, 80)
let tags: String = "[\"Knowledge\",\"captured\"]" let tags: String = "[\"Knowledge\",\"captured\"]"
let id: String = engram_node_full(full, "Knowledge", lbl, let id: String = wt_node(full, "Knowledge", lbl,
el_from_float(0.85), el_from_float(0.8), el_from_float(0.9), el_from_float(0.85), el_from_float(0.8), el_from_float(0.9),
"Episodic", tags) "Episodic", tags)
if !api_persisted(id) { return api_not_persisted(id) } if !api_persisted(id) { return api_not_persisted(id) }
@@ -518,12 +559,12 @@ fn handle_api_evolve_knowledge(body: String) -> String {
if !str_eq(prior_id, "") && is_protected_node(prior_id) { return api_err_protected(prior_id) } if !str_eq(prior_id, "") && is_protected_node(prior_id) { return api_err_protected(prior_id) }
let tags: String = "[\"Knowledge\",\"evolved\"]" let tags: String = "[\"Knowledge\",\"evolved\"]"
// Empty label engram_node_full derives content[:60] (LABEL FIX 2026-07-23). // Empty label engram_node_full derives content[:60] (LABEL FIX 2026-07-23).
let new_id: String = engram_node_full(content, "Knowledge", "", let new_id: String = wt_node(content, "Knowledge", "",
el_from_float(0.75), el_from_float(0.75), el_from_float(0.9), el_from_float(0.75), el_from_float(0.75), el_from_float(0.9),
"Episodic", tags) "Episodic", tags)
if !api_persisted(new_id) { return api_not_persisted(new_id) } if !api_persisted(new_id) { return api_not_persisted(new_id) }
if !str_eq(prior_id, "") { if !str_eq(prior_id, "") {
engram_connect(new_id, prior_id, el_from_float(0.9), "supersedes") wt_edge(new_id, prior_id, el_from_float(0.9), "supersedes")
} }
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true}" return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true}"
} }
@@ -540,11 +581,11 @@ fn handle_api_promote_knowledge(body: String) -> String {
"[\"Knowledge\",\"tier:canonical\",\"disposition:stable\"]" "[\"Knowledge\",\"tier:canonical\",\"disposition:stable\"]"
} else { tags_raw } } else { tags_raw }
// Empty label engram_node_full derives content[:60] (LABEL FIX 2026-07-23). // Empty label engram_node_full derives content[:60] (LABEL FIX 2026-07-23).
let new_id: String = engram_node_full(content, "Knowledge", "", let new_id: String = wt_node(content, "Knowledge", "",
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0), el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
"Canonical", tags) "Canonical", tags)
if !api_persisted(new_id) { return api_not_persisted(new_id) } if !api_persisted(new_id) { return api_not_persisted(new_id) }
engram_connect(new_id, prior_id, el_from_float(0.95), "supersedes") wt_edge(new_id, prior_id, el_from_float(0.95), "supersedes")
return "{\"ok\":true,\"new_id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\"}" return "{\"ok\":true,\"new_id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\"}"
} }
@@ -567,7 +608,7 @@ fn handle_api_define_process(body: String) -> String {
if str_eq(content, "") { return api_err("content is required") } if str_eq(content, "") { return api_err("content is required") }
let label: String = if str_eq(name, "") { "process:unnamed" } else { "process:" + name } let label: String = if str_eq(name, "") { "process:unnamed" } else { "process:" + name }
let tags: String = "[\"Process\"]" let tags: String = "[\"Process\"]"
let id: String = engram_node_full(content, "Process", label, let id: String = wt_node(content, "Process", label,
el_from_float(0.8), el_from_float(0.8), el_from_float(0.9), el_from_float(0.8), el_from_float(0.8), el_from_float(0.9),
"Canonical", tags) "Canonical", tags)
if !api_persisted(id) { return api_not_persisted(id) } if !api_persisted(id) { return api_not_persisted(id) }
@@ -652,7 +693,7 @@ fn handle_api_tune_config(body: String) -> String {
if str_eq(key, "") { return api_err("key is required") } if str_eq(key, "") { return api_err("key is required") }
let content: String = "config:" + key + "=" + value let content: String = "config:" + key + "=" + value
let tags: String = "[\"ConfigEntry\",\"config\"]" let tags: String = "[\"ConfigEntry\",\"config\"]"
let id: String = engram_node_full(content, "ConfigEntry", key, let id: String = wt_node(content, "ConfigEntry", key,
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9), el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
"Canonical", tags) "Canonical", tags)
if !api_persisted(id) { return api_not_persisted(id) } if !api_persisted(id) { return api_not_persisted(id) }
@@ -699,7 +740,7 @@ fn handle_api_link_entities(body: String) -> String {
if is_protected_node(to_id) { return api_err_protected(to_id) } if is_protected_node(to_id) { return api_err_protected(to_id) }
let relation: String = json_get(body, "relation") let relation: String = json_get(body, "relation")
let eff_relation: String = if str_eq(relation, "") { "associates" } else { relation } let eff_relation: String = if str_eq(relation, "") { "associates" } else { relation }
engram_connect(from_id, to_id, el_from_float(0.5), eff_relation) wt_edge(from_id, to_id, el_from_float(0.5), eff_relation)
return "{\"ok\":true,\"from_id\":\"" + from_id + "\",\"to_id\":\"" + to_id + "\",\"relation\":\"" + eff_relation + "\"}" return "{\"ok\":true,\"from_id\":\"" + from_id + "\",\"to_id\":\"" + to_id + "\",\"relation\":\"" + eff_relation + "\"}"
} }
@@ -732,11 +773,11 @@ fn handle_api_evolve_memory(body: String) -> String {
} }
} }
let tags: String = "[\"Memory\",\"evolved\"]" let tags: String = "[\"Memory\",\"evolved\"]"
let new_id: String = engram_node_full(content, "Memory", "memory:evolved", let new_id: String = wt_node(content, "Memory", "memory:evolved",
sal, sal, el_from_float(0.9), sal, sal, el_from_float(0.9),
"Episodic", tags) "Episodic", tags)
if !str_eq(prior_id, "") && !str_eq(new_id, "") { if !str_eq(prior_id, "") && !str_eq(new_id, "") {
engram_connect(new_id, prior_id, el_from_float(0.9), "supersedes") wt_edge(new_id, prior_id, el_from_float(0.9), "supersedes")
} }
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true}" return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true}"
} }
@@ -794,11 +835,11 @@ fn handle_api_cultivate(body: String) -> String {
let content: String = json_get(body, "content") let content: String = json_get(body, "content")
if str_eq(content, "") { return api_err("content is required") } if str_eq(content, "") { return api_err("content is required") }
let tags: String = "[\"Knowledge\",\"evolved\",\"cultivated\"]" let tags: String = "[\"Knowledge\",\"evolved\",\"cultivated\"]"
let new_id: String = engram_node_full(content, "Knowledge", "knowledge:cultivated", let new_id: String = wt_node(content, "Knowledge", "knowledge:cultivated",
el_from_float(0.75), el_from_float(0.75), el_from_float(0.9), el_from_float(0.75), el_from_float(0.75), el_from_float(0.9),
"Episodic", tags) "Episodic", tags)
if !str_eq(prior_id, "") && !str_eq(new_id, "") { if !str_eq(prior_id, "") && !str_eq(new_id, "") {
engram_connect(new_id, prior_id, el_from_float(0.9), "supersedes") wt_edge(new_id, prior_id, el_from_float(0.9), "supersedes")
} }
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true,\"cultivated\":true}" return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true,\"cultivated\":true}"
} }
@@ -814,11 +855,11 @@ fn handle_api_cultivate(body: String) -> String {
} }
} }
let tags: String = "[\"Memory\",\"evolved\",\"cultivated\"]" let tags: String = "[\"Memory\",\"evolved\",\"cultivated\"]"
let new_id: String = engram_node_full(content, "Memory", "memory:cultivated", let new_id: String = wt_node(content, "Memory", "memory:cultivated",
sal, sal, el_from_float(0.9), sal, sal, el_from_float(0.9),
"Episodic", tags) "Episodic", tags)
if !str_eq(prior_id, "") && !str_eq(new_id, "") { if !str_eq(prior_id, "") && !str_eq(new_id, "") {
engram_connect(new_id, prior_id, el_from_float(0.9), "supersedes") wt_edge(new_id, prior_id, el_from_float(0.9), "supersedes")
} }
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true,\"cultivated\":true}" return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + prior_id + "\",\"ok\":true,\"cultivated\":true}"
} }
@@ -838,7 +879,7 @@ fn handle_api_cultivate(body: String) -> String {
if str_eq(to_id, "") { return api_err("to_id is required") } if str_eq(to_id, "") { return api_err("to_id is required") }
let relation: String = json_get(body, "relation") let relation: String = json_get(body, "relation")
let eff_relation: String = if str_eq(relation, "") { "associates" } else { relation } let eff_relation: String = if str_eq(relation, "") { "associates" } else { relation }
engram_connect(from_id, to_id, el_from_float(0.5), eff_relation) wt_edge(from_id, to_id, el_from_float(0.5), eff_relation)
return "{\"ok\":true,\"from_id\":\"" + from_id + "\",\"to_id\":\"" + to_id + "\",\"relation\":\"" + eff_relation + "\",\"cultivated\":true}" return "{\"ok\":true,\"from_id\":\"" + from_id + "\",\"to_id\":\"" + to_id + "\",\"relation\":\"" + eff_relation + "\",\"cultivated\":true}"
} }
@@ -873,7 +914,7 @@ fn handle_api_consolidate(body: String) -> String {
if !str_eq(summary, "") { if !str_eq(summary, "") {
let safe_summary: String = str_replace(summary, "\"", "'") let safe_summary: String = str_replace(summary, "\"", "'")
let tags: String = "[\"SessionSummary\",\"consolidate\"]" let tags: String = "[\"SessionSummary\",\"consolidate\"]"
let summary_id: String = engram_node_full( let summary_id: String = wt_node(
"[session-summary] " + safe_summary, "[session-summary] " + safe_summary,
"SessionSummary", "session:summary", "SessionSummary", "session:summary",
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
+426
View File
@@ -0,0 +1,426 @@
// persist.el the soulengram WRITE-THROUGH boundary (neuron#117).
//
// WHY THIS FILE EXISTS
// soul.el:571-573 states the ownership rule: "when ENGRAM_URL is set the HTTP
// Engram owns persistence the soul must NEVER write to the local snapshot
// (not the persistence owner)." The soul obeys the NEGATIVE half. The POSITIVE
// half how a write made inside the soul actually REACHES the owner was
// never built. Sync is pull-only (awareness.el `/api/sync` -> engram_load_merge),
// so every node the soul creates lives in its process RAM and is shed on
// restart. Measured live 2026-08-07: soul node_count=102184, engram
// node_count=79197 ~23k nodes existing nowhere but RAM.
//
// SCOPE NOTE ON THE PATENT (corrects an earlier internal reading)
// Engram provisional claims 15-18 describe a delta-sync protocol "with peer
// Engram instances"; claim 17's pull-then-push sequence is PEER-ENGRAM to
// PEER-ENGRAM. The soul is NOT a peer Engram it is a CALLER of the database
// system API (cf. claim 27, "invoked explicitly by a caller of the database
// system API"). So claim 17 does not specify a soul↔engram contract and is not
// cited as authority here. This design is derived from the ownership rule
// alone: the owner owns the writes, therefore the soul must HAND writes to the
// owner and must never write the owner's file itself.
//
// THE MECHANISM, AND WHY NOT `POST /api/nodes`
// The obvious route is the one the persona/boot-counter write-backs already
// use, POST /api/nodes. It is the wrong instrument here, verified against the
// live engram binary in a sandbox:
// - it mints a NEW server-side id (engram_node), so the soul's id and the
// owner's id diverge the next /api/sync pull re-imports the node as a
// DUPLICATE, and any edge referencing the soul's id never resolves;
// - it accepts only {content, node_type, salience} and drops label, tier,
// tags, importance, confidence, metadata. A probe posted with tier
// "Canonical" came back tier "Working", importance 0.5.
// POST /api/load-merge (Will's own route, el `dc39a61`) is the right one:
// - engram_load_merge PRESERVES the id and every field;
// - it dedups nodes by id and edges by (from_id,to_id,relation), so a
// re-submitted delta is a NO-OP retry safety is free, and it is the same
// local-wins semantics the graph already uses;
// - it calls persist_canonical() THE OWNER writes its own canonical file.
// The soul never touches it. The ownership rule is honoured in its
// strongest form rather than worked around;
// - it returns real counts {ok, nodes_added, edges_added, node_count},
// so a receipt can be a MEASUREMENT instead of a fixed success shape.
//
// SPOOL-AND-DRAIN, AND WHY IT IS NOT JUST A DIRECT POST
// Measured in a sandbox against a 79k-node / 176MB graph (live scale): one
// load-merge costs ~0.38s, essentially all of it the owner's persist_canonical.
// A chat turn writes 5-7 nodes; pushing each separately would add ~2.7s per
// turn. So writes are STAGED and pushed in one coalesced batch.
// The staging buffer is the FILESYSTEM, not process state, because the soul
// serves each HTTP connection on its own pthread (el_runtime http_serve_async)
// and a shared in-process buffer would lose entries to a read-modify-write
// race silently, which is the one failure mode this file exists to end.
// One file per write, named with uuid_v4, is race-free by construction and
// buys a property a memory buffer cannot: writes that could not be pushed
// SURVIVE A SOUL CRASH and are drained on the next boot.
//
// WHAT IS DELIBERATELY NOT PUSHED
// - InternalStateEvent / heartbeat telemetry. Will's own carve-out, stated in
// engram server.el 8f8ccc9: "48h-pruned, loss-tolerant, ~2/min; snapshotting
// 28MB per heartbeat is waste."
// NOTE (ours, flagged for Will): we do NOT additionally exclude Working-tier
// nodes. That exclusion exists in `fb0bb55` to stop the boot counter leaking
// through the /api/sync PULL; it is about sync backflow, not durability.
// Applying it here would exclude mem_store which writes tier "Working" and
// mem_store is the single most important durable write path in the soul. Boot
// seeding reads the canonical file wholesale, so a pushed Working-tier node
// does survive restart. This is the one classification call this file makes
// that Will has not ruled on.
//
// WHAT THIS BOUNDARY CANNOT EXPRESS (by construction, not by omission)
// - engram_strengthen (salience/activation drift): load-merge SKIPS ids that
// already exist, so it cannot update an existing node. There is no owner-side
// update/upsert route. Not pushable through any current route; left as a
// follow-up that needs a change in the engram repo.
// - engram_forget (hard delete): load-merge is additive and has no delete verb.
// Propagating deletes would mean DELETE /api/nodes/<id>, a HARD delete at the
// owner which scripts/verify-soul-contract.sh section B explicitly fails the
// build for ("to delete is to supersede/tombstone, never hard-remove"). Local
// deletes therefore stay local; the TOMBSTONE NODE and its "tombstones" edge
// (mem_tombstone) are pushed, and that is the sanctioned representation of a
// deletion in this graph.
// Configuration
// wt_engram_url same resolution order as ise_post: env, then the state key
// stashed at boot. NO hardcoded localhost fallback: unlike telemetry, inventing
// a destination for durable data would risk pushing a user's memories at whatever
// happens to be listening on 8742. Empty means "no HTTP owner" -> file mode.
fn wt_engram_url() -> String {
let env_url: String = env("ENGRAM_URL")
if !str_eq(env_url, "") { return env_url }
return state_get("soul_engram_url")
}
fn wt_api_key() -> String {
let env_key: String = env("ENGRAM_API_KEY")
if !str_eq(env_key, "") { return env_key }
return state_get("soul_engram_api_key")
}
// wt_enabled true only in HTTP-engram mode. In file mode the soul IS the
// persistence owner and every path below is a no-op, so this whole feature is
// inert for genesis/local deployments. That is also what makes it reversible.
fn wt_enabled() -> Bool {
return !str_eq(wt_engram_url(), "")
}
// wt_spool_dir where staged deltas live. MUST be readable by the engram
// process: /api/load-merge takes a PATH and the owner opens it itself. Both
// processes are same-host by construction (dev-stack LaunchAgents; the GKE
// image starts engram and soul in one container per entrypoint.sh).
fn wt_spool_dir() -> String {
let raw: String = env("SOUL_OUTBOX_DIR")
let dir: String = if str_eq(raw, "") { env("HOME") + "/.neuron/soul-outbox" } else { raw }
fs_mkdir(dir)
return dir
}
// Helpers
// wt_esc minimal JSON string escape. Deliberately local rather than reusing
// chat.el's json_safe: persist.el is imported BY memory.el, which is imported by
// chat.el, so depending on chat.el here would be an import cycle.
fn wt_esc(s: String) -> String {
let s1: String = str_replace(s, "\\", "\\\\")
let s2: String = str_replace(s1, "\"", "\\\"")
let s3: String = str_replace(s2, "\n", "\\n")
let s4: String = str_replace(s3, "\r", "\\r")
let s5: String = str_replace(s4, "\t", "\\t")
return s5
}
// wt_durable_class Will's telemetry carve-out, by node_type. See header.
fn wt_durable_class(node_type: String) -> Bool {
if str_eq(node_type, "InternalStateEvent") { return false }
return true
}
// wt_inner strip the surrounding brackets off a JSON array so several arrays
// can be concatenated into one. Returns "" for "[]" / "" / anything too short.
fn wt_inner(arr: String) -> String {
let n: Int = str_len(arr)
if n < 3 { return "" }
if !str_starts_with(arr, "[") { return "" }
return str_slice(arr, 1, n - 1)
}
// wt_read fs_read, plus a MANDATORY reset of the runtime's binary-length hint.
//
// THIS IS NOT OPTIONAL AND MUST NOT BE "SIMPLIFIED" BACK TO A BARE fs_read.
// The pinned runtime (vendor/el-runtime/v1.0.0-20260501) keeps a thread-local
// `_tl_fs_read_len` that fs_read SETS to the file's byte count (so binary files
// can be served with a correct Content-Length) and that http_send_response
// CONSUMES as the Content-Length of the next reply. Nothing else clears it
// except json_get_raw. So any fs_read during request handling that is not
// followed by a json_get_raw makes the NEXT HTTP response advertise the FILE's
// length instead of the body's and the runtime then sends that many bytes,
// appending whatever adjacent heap memory follows the reply.
//
// Caught here, measured: a /api/neuron/memory reply that should be 86 bytes went
// out as 497, with 411 bytes of this module's own spool paths and log strings
// trailing the JSON. The drain reads spool files mid-request, so this boundary
// is exactly where the landmine gets stepped on.
//
// Upstream el fixed the class in `43636ae` ("pair fs_read length hint with its
// buffer"); that runtime is NOT the one vendored here, and re-pinning the
// runtime is deliberately out of scope for this change. Clearing the hint at
// our own boundary fixes our exposure without touching the pinned C.
// json_get_raw is used as the reset because it is the only builtin in this
// runtime that zeroes the hint, and it does so before any early return.
fn wt_clear_binlen() -> Void {
let discard: String = json_get_raw("{}", "_wt_reset")
}
fn wt_read(path: String) -> String {
let data: String = fs_read(path)
wt_clear_binlen()
return data
}
// wt_sweep best-effort removal of the zero-byte husks left by truncation.
// The runtime exposes no unlink builtin, so a drained delta is emptied rather
// than deleted; this reclaims the directory entries.
//
// `-empty` is the safety property, not an optimisation: the command is
// STRUCTURALLY INCAPABLE of removing a delta that still has content, so it can
// never destroy a pending write even if it runs concurrently with a stage.
// Only the directory path is interpolated (never a filename), and it is quoted.
// The exit code is ignored an un-swept husk costs one directory entry.
fn wt_sweep(dir: String) -> Void {
if str_eq(dir, "") { return }
if str_contains(dir, "'") { return }
exec_command("find '" + dir + "' -maxdepth 1 -name 'wt*.json' -empty -delete 2>/dev/null")
}
// Staging
// wt_stage write ONE delta file. uuid_v4 in the name makes concurrent stagers
// collision-free without any lock. Returns true if the delta is on disk.
fn wt_stage(nodes_json: String, edges_json: String) -> Bool {
let dir: String = wt_spool_dir()
if str_eq(dir, "") { return false }
let payload: String = "{\"nodes\":" + nodes_json + ",\"edges\":" + edges_json + "}"
let path: String = dir + "/wt-" + uuid_v4() + ".json"
fs_write(path, payload)
// Read-back-verify the stage itself. A stage that did not land is a write we
// would otherwise believe was queued exactly the hallucinated-save class.
if str_eq(wt_read(path), "") {
println("[persist] wt_stage: FAILED to write spool file " + path + " — delta not queued")
return false
}
return true
}
// The write boundary
// wt_node create a node locally AND queue it for the persistence owner.
// Same signature and same return contract as engram_node_full ("" on failure),
// so converting a call site is a rename and nothing else.
fn wt_node(content: String, node_type: String, label: String,
salience: Float, importance: Float, confidence: Float,
tier: String, tags: String) -> String {
let id: String = engram_node_full(content, node_type, label,
salience, importance, confidence,
tier, tags)
if str_eq(id, "") { return "" }
// engram_get_node_json emits the SAME record shape engram_save writes (minus
// the embedding vector, which the owner backfills lazily), so the read-back
// doubles as the delta payload no second serialization to drift.
let rec: String = engram_get_node_json(id)
if str_eq(rec, "") || str_eq(rec, "{}") {
println("[persist] wt_node: local write did not read back, id=" + id + " label=" + label)
return ""
}
if wt_enabled() && wt_durable_class(node_type) {
wt_stage("[" + rec + "]", "[]")
}
return id
}
// wt_edge create an edge locally AND queue it. Mirrors engram_connect.
//
// The edge id is freshly generated rather than read back: the runtime exposes no
// "id of the edge I just created" accessor, and the owner dedups edges by
// (from_id,to_id,relation), never by id so the id is not load-bearing. The
// consequence, stated plainly: the soul's copy and the owner's copy of the same
// edge carry different edge ids. Nothing in either codebase looks an edge up by
// id (neighbors traversal scans from_id/to_id), so this is cosmetic.
fn wt_edge(from_id: String, to_id: String, weight: Float, relation: String) -> Void {
engram_connect(from_id, to_id, weight, relation)
if !wt_enabled() { return }
if str_eq(from_id, "") || str_eq(to_id, "") { return }
let ts: Int = time_now()
let rec: String = "{\"id\":\"" + uuid_v4() + "\""
+ ",\"from_id\":\"" + wt_esc(from_id) + "\""
+ ",\"to_id\":\"" + wt_esc(to_id) + "\""
+ ",\"relation\":\"" + wt_esc(relation) + "\""
+ ",\"metadata\":\"{}\""
+ ",\"weight\":" + float_to_str(weight)
+ ",\"confidence\":1"
+ ",\"created_at\":" + int_to_str(ts)
+ ",\"updated_at\":" + int_to_str(ts)
+ ",\"last_fired\":0,\"inhibitory\":0,\"layer_id\":1}"
wt_stage("[]", "[" + rec + "]")
}
// The drain
// wt_drain coalesce every staged delta into ONE load-merge against the owner.
//
// Returns: nodes_added on success (>= 0), 0 when there was nothing to do, and
// -1 when the push FAILED. -1 is load-bearing: on failure the spool files are
// left untouched, so nothing is lost and the next drain retries them. A caller
// must never read a non-negative return as "my particular node is durable"
// use wt_durable(id) for that.
//
// Concurrency: several threads may drain at once. Each builds its own batch file
// (uuid-named), and overlapping batches are harmless because load-merge dedups.
// Files are truncated ONLY after a confirmed ok:true, so a lost race costs a
// redundant push, never a dropped write.
fn wt_drain() -> Int {
if !wt_enabled() { return 0 }
let dir: String = wt_spool_dir()
if str_eq(dir, "") { return 0 }
// el_list_len/el_list_get, NOT json_stringify(fs_list(...)): fs_list builds
// a native list via el_list_append, and json_stringify does not serialize
// that type it renders the raw pointer value. (Verified in isolation; the
// same latent defect is live in studio.el's /api/tools/file/list route,
// which returns e.g. {"entries":4386409744}. Noted, not fixed here.)
let listing = fs_list(dir)
let count: Int = el_list_len(listing)
if count == 0 { return 0 }
let nodes_acc: String = ""
let edges_acc: String = ""
let drained: String = ""
let found: Int = 0
let i: Int = 0
// No `continue` / `break`: elc lists them as keywords but not one line of
// the shipped soul uses either, so they are unexercised on this build path.
// Guard conditions are expressed as nested ifs instead, and every rebind is
// at the loop-body top level where `let x = ...` is assignment (the idiom
// memory.el's boot-counter loop relies on) never inside a nested block,
// where it would shadow instead.
while i < count {
let name: String = el_list_get(listing, i)
// A delta is only usable when it ends with the closing "]}" that
// wt_stage writes last. fs_write is not atomic, so a file being written
// right now can be observed half-formed; requiring the terminator means
// it is picked up whole on the next drain instead of merged as garbage.
// An empty read means "already drained and truncated" not an error.
let p: String = if str_starts_with(name, "wt-") { dir + "/" + name } else { "" }
let raw: String = if str_eq(p, "") { "" } else { wt_read(p) }
let usable: Bool = !str_eq(raw, "") && str_ends_with(raw, "]}")
let nj: String = if usable { wt_inner(json_get_raw(raw, "nodes")) } else { "" }
let ej: String = if usable { wt_inner(json_get_raw(raw, "edges")) } else { "" }
let nodes_acc = if str_eq(nj, "") { nodes_acc } else if str_eq(nodes_acc, "") { nj } else { nodes_acc + "," + nj }
let edges_acc = if str_eq(ej, "") { edges_acc } else if str_eq(edges_acc, "") { ej } else { edges_acc + "," + ej }
let drained = if !usable { drained } else if str_eq(drained, "") { p } else { drained + "\n" + p }
let found = if usable { found + 1 } else { found }
let i = i + 1
}
if found == 0 { return 0 }
let combined: String = "{\"nodes\":[" + nodes_acc + "],\"edges\":[" + edges_acc + "]}"
let batch: String = dir + "/wtb-" + uuid_v4() + ".json"
fs_write(batch, combined)
if str_eq(wt_read(batch), "") {
println("[persist] wt_drain: could not write batch file " + batch + "" + int_to_str(found) + " deltas stay queued")
return -1
}
let url: String = wt_engram_url()
let key: String = wt_api_key()
let body: String = "{\"path\":\"" + wt_esc(batch) + "\",\"_auth\":\"" + wt_esc(key) + "\"}"
let resp: String = http_post_json(url + "/api/load-merge", body)
// The batch file is pure scratch the retry is rebuilt from the SPOOL, not
// from it. Truncate it unconditionally, before branching on the outcome, so
// a persistently unreachable owner cannot accumulate one husk per attempt.
fs_write(batch, "")
// Distinguish the two failures rather than collapsing them: "cannot reach
// the owner" and "the owner refused this delta" need different human
// responses, and a log line that says the wrong one costs a debugging hour.
// curl surfaces transport errors as a JSON body, so an empty response is not
// the only unreachable signal.
// (str_contains rather than a strict parse on purpose the engram's HTTP
// responses have been observed carrying trailing bytes past the JSON.)
let unreachable: Bool = str_eq(resp, "")
|| str_contains(resp, "Couldn't connect")
|| str_contains(resp, "Failed to connect")
|| str_contains(resp, "Could not resolve")
|| str_contains(resp, "timed out")
if unreachable {
wt_sweep(dir)
println("[persist] wt_drain: owner UNREACHABLE at " + url + "" + int_to_str(found)
+ " deltas stay queued in " + dir + " (will retry): " + resp)
return -1
}
if !str_contains(resp, "\"ok\":true") {
wt_sweep(dir)
println("[persist] wt_drain: owner REJECTED the delta — " + int_to_str(found)
+ " stay queued in " + dir + ": " + resp)
return -1
}
let added: Int = json_get_int(resp, "nodes_added")
let added_e: Int = json_get_int(resp, "edges_added")
// Confirmed. Truncate the drained spool files so they are not re-pushed.
// Truncation (not deletion) because the runtime exposes no unlink builtin;
// an emptied file is inert to the loop above. The zero-byte husks are then
// swept below.
let paths = str_split(drained, "\n")
let pn: Int = el_list_len(paths)
let k: Int = 0
while k < pn {
let one: String = el_list_get(paths, k)
if !str_eq(one, "") { fs_write(one, "") }
let k = k + 1
}
wt_sweep(dir)
println("[persist] wt_drain: pushed " + int_to_str(found) + " deltas -> owner added "
+ int_to_str(added) + " nodes, " + int_to_str(added_e) + " edges")
return added
}
// wt_durable is this id present AT THE OWNER? The only honest answer to
// "did my write persist" in HTTP mode.
//
// In file mode the soul IS the owner, so the local read-back is the owner-side
// read-back and this collapses to the pre-existing check.
//
// nodes_added from wt_drain is NOT a substitute: a concurrent drain may have
// already pushed this node, making our own added count 0 while the node is
// perfectly durable. Presence at the owner is the fact; counts are telemetry.
fn wt_durable(id: String) -> Bool {
if str_eq(id, "") { return false }
if !wt_enabled() {
let local: String = engram_get_node_json(id)
return !str_eq(local, "") && !str_eq(local, "null") && !str_eq(local, "{}")
}
let url: String = wt_engram_url()
let resp: String = http_get(url + "/api/nodes/" + id)
if str_eq(resp, "") { return false }
if str_eq(resp, "{}") { return false }
return str_contains(resp, "\"id\"")
}
// wt_commit flush, then assert at the owner. The receipt callers should use.
// Deliberately NOT a fixed success shape: it can and does return false while the
// local write is perfectly fine in RAM, which is the true state of affairs when
// the owner is unreachable.
fn wt_commit(id: String) -> Bool {
if str_eq(id, "") { return false }
if !wt_enabled() {
let local: String = engram_get_node_json(id)
return !str_eq(local, "") && !str_eq(local, "null") && !str_eq(local, "{}")
}
let pushed: Int = wt_drain()
return wt_durable(id)
}
+45 -7
View File
@@ -186,7 +186,7 @@ fn route_imprint_contextual(body: String) -> String {
return "{\"ok\":false,\"error\":\"empty body\"}" return "{\"ok\":false,\"error\":\"empty body\"}"
} }
let tags: String = "[\"imprint\",\"contextual\"]" let tags: String = "[\"imprint\",\"contextual\"]"
let id: String = engram_node_full( let id: String = wt_node(
body, body,
"Entity", "Entity",
"imprint:contextual", "imprint:contextual",
@@ -208,7 +208,7 @@ fn route_imprint_user(body: String) -> String {
return "{\"ok\":false,\"error\":\"empty body\"}" return "{\"ok\":false,\"error\":\"empty body\"}"
} }
let tags: String = "[\"imprint\",\"user\"]" let tags: String = "[\"imprint\",\"user\"]"
let id: String = engram_node_full( let id: String = wt_node(
body, body,
"Entity", "Entity",
"imprint:user", "imprint:user",
@@ -239,7 +239,7 @@ fn route_synthesize(body: String) -> String {
} }
let req: String = "synthesize " + parent_a + " " + parent_b let req: String = "synthesize " + parent_a + " " + parent_b
let tags: String = "[\"soul-inbox-pending\",\"synthesis-request\"]" let tags: String = "[\"soul-inbox-pending\",\"synthesis-request\"]"
engram_node_full( wt_node(
req, req,
"Entity", "Entity",
"synthesis-request", "synthesis-request",
@@ -395,7 +395,28 @@ fn handle_connectors(method: String, clean: String, body: String) -> String {
return "{\"ok\":false,\"error\":\"unknown connectors route\"}" return "{\"ok\":false,\"error\":\"unknown connectors route\"}"
} }
// handle_request the soul's HTTP entry point.
//
// NOTE ON THE NAME (neuron#117): the el runtime resolves this handler by NAME
// via dlsym(RTLD_DEFAULT, "handle_request") that is why the Linux build must
// link -rdynamic. So the dispatcher body moved to route_dispatch and the name
// `handle_request` stays put as a thin wrapper. Do not rename it back.
//
// The wrapper exists to give the write-through boundary a guaranteed flush
// point. route_dispatch returns from ~60 places; a per-branch flush would be
// forgotten on the 61st. Draining here means EVERY request that staged a write
// pushes it before the connection closes, whatever route produced it, including
// routes added later that know nothing about persistence.
//
// wt_drain is a no-op (no HTTP, no cost) when nothing is staged and when the
// soul is not in HTTP-engram mode, so this is free on read traffic.
fn handle_request(method: String, path: String, body: String) -> String { fn handle_request(method: String, path: String, body: String) -> String {
let resp: String = route_dispatch(method, path, body)
let flushed: Int = wt_drain()
return resp
}
fn route_dispatch(method: String, path: String, body: String) -> String {
let clean: String = strip_query(path) let clean: String = strip_query(path)
// ACTIVITY STAMP (2026-07-30 self-review): every inbound HTTP request // ACTIVITY STAMP (2026-07-30 self-review): every inbound HTTP request
@@ -432,10 +453,27 @@ fn handle_request(method: String, path: String, body: String) -> String {
return engram_scan_nodes_json(9999, 0) return engram_scan_nodes_json(9999, 0)
} }
if str_eq(clean, "/api/graph/edges") { if str_eq(clean, "/api/graph/edges") {
// TODO(reliability #8): engram_save races with awareness loop mem_save(). // FIXED (neuron#117): this GET used to engram_save() straight over
// Both now use atomic write-to-temp+rename (el_runtime.c). Serialised // ~/.neuron/engram/snapshot.json a READ route, in a process that is
// by engram_global_mu. Future: add engram_edges_json() builtin. // NOT the persistence owner, overwriting the owner's canonical file
let snap_path: String = env("HOME") + "/.neuron/engram/snapshot.json" // on every call. It broke soul.el:571-573 ("the soul must NEVER write
// to the local snapshot") and it is the same defect class Will removed
// from the engram itself in el `dc39a61` ("stop read routes clobbering
// canonical snapshot"), where route_scan_edges/route_sync were moved
// to scratch paths for exactly this reason. It was also the race the
// old TODO(reliability #8) admitted to.
//
// Export to a scratch path instead. Same response, no canonical write.
// The soul's own snapshot writes are otherwise already gated behind
// state key "soul_snapshot_path", which is set ONLY in the genesis
// file-mode branch (soul.el: is_genesis && safe_to_seed, and
// safe_to_seed is unconditionally false when ENGRAM_URL is set) so
// after this change the soul writes nothing at all in HTTP mode.
// Future: add an engram_edges_json() builtin and drop the file round
// trip entirely.
let scratch_dir: String = env("TMPDIR")
let scratch_base: String = if str_eq(scratch_dir, "") { "/tmp" } else { scratch_dir }
let snap_path: String = scratch_base + "/soul-edges-export-" + state_get("soul_cgi_id") + ".json"
engram_save(snap_path) engram_save(snap_path)
let snap: String = fs_read(snap_path) let snap: String = fs_read(snap_path)
let edges_raw: String = json_get_raw(snap, "edges") let edges_raw: String = json_get_raw(snap, "edges")
+1 -1
View File
@@ -204,7 +204,7 @@ fn safety_log_bell(level: String, reason: String, input_summary: String) -> Stri
// Emit a fallback println so the bell event leaves at least a log trace even // Emit a fallback println so the bell event leaves at least a log trace even
// when engram is degraded. This does not replace engram persistence -- it is a // when engram is degraded. This does not replace engram persistence -- it is a
// last-resort audit trail when the primary write cannot be confirmed. // last-resort audit trail when the primary write cannot be confirmed.
let node_id: String = engram_node_full( let node_id: String = wt_node(
content, content,
"BellEvent", "BellEvent",
"bell:" + level, "bell:" + level,
+7 -7
View File
@@ -87,7 +87,7 @@ fn session_create(body: String) -> String {
let folder: String = json_get(body, "folder") let folder: String = json_get(body, "folder")
let content: String = session_make_content(id, title, ts, ts, folder) let content: String = session_make_content(id, title, ts, ts, folder)
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]" let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
let node_id: String = engram_node_full( let node_id: String = wt_node(
content, "Conversation", "session:meta", content, "Conversation", "session:meta",
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
"Episodic", tags "Episodic", tags
@@ -358,7 +358,7 @@ fn session_update_patch(session_id: String, body: String) -> String {
let created_int: Int = str_to_int(old_created) let created_int: Int = str_to_int(old_created)
let new_content: String = session_make_content(session_id, eff_title, created_int, ts, eff_folder) let new_content: String = session_make_content(session_id, eff_title, created_int, ts, eff_folder)
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]" let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
let new_node_id: String = engram_node_full( let new_node_id: String = wt_node(
new_content, "Conversation", "session:meta", new_content, "Conversation", "session:meta",
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
"Episodic", tags "Episodic", tags
@@ -456,7 +456,7 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
// TODO(reliability #7): delete-then-insert is not atomic concurrent saves for the // TODO(reliability #7): delete-then-insert is not atomic concurrent saves for the
// same session can produce orphan history nodes. State is primary truth; engram fallback. // same session can produce orphan history nodes. State is primary truth; engram fallback.
let tags: String = "[\"session\",\"session-history\",\"Conversation\"]" let tags: String = "[\"session\",\"session-history\",\"Conversation\"]"
let discard: String = engram_node_full( let discard: String = wt_node(
hist, "Conversation", "session:messages:" + session_id, hist, "Conversation", "session:messages:" + session_id,
el_from_float(0.6), el_from_float(0.6), el_from_float(0.9), el_from_float(0.6), el_from_float(0.6), el_from_float(0.9),
"Episodic", tags "Episodic", tags
@@ -488,7 +488,7 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
+ " | ts:" + int_to_str(ts_now) + " | ts:" + int_to_str(ts_now)
let summary_tags: String = "[\"session-emotional-summary\",\"affective\",\"bell:" + eff_level + "\",\"BellEvent\"]" let summary_tags: String = "[\"session-emotional-summary\",\"affective\",\"bell:" + eff_level + "\",\"BellEvent\"]"
let summary_sal: String = if str_eq(eff_level, "hard") { el_from_float(0.95) } else { el_from_float(0.85) } let summary_sal: String = if str_eq(eff_level, "hard") { el_from_float(0.95) } else { el_from_float(0.85) }
let sum_discard: String = engram_node_full( let sum_discard: String = wt_node(
summary_content, summary_content,
"BellEvent", "BellEvent",
"session:emotional-summary", "session:emotional-summary",
@@ -529,7 +529,7 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
if !str_eq(ot_id, "") { engram_forget(ot_id) } if !str_eq(ot_id, "") { engram_forget(ot_id) }
let oti = oti + 1 let oti = oti + 1
} }
let discard_topic: String = engram_node_full( let discard_topic: String = wt_node(
topic_content, "Conversation", topic_label, topic_content, "Conversation", topic_label,
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
"Episodic", topic_tags "Episodic", topic_tags
@@ -582,7 +582,7 @@ fn session_update_meta_timestamp(session_id: String) -> Void {
let created_int: Int = str_to_int(old_created) let created_int: Int = str_to_int(old_created)
let new_content: String = session_make_content(session_id, old_title, created_int, ts, old_folder) let new_content: String = session_make_content(session_id, old_title, created_int, ts, old_folder)
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]" let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
let new_id: String = engram_node_full( let new_id: String = wt_node(
new_content, "Conversation", "session:meta", new_content, "Conversation", "session:meta",
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
"Episodic", tags "Episodic", tags
@@ -629,7 +629,7 @@ fn session_auto_title(session_id: String, first_message: String) -> Void {
let created_int: Int = str_to_int(old_created) let created_int: Int = str_to_int(old_created)
let new_content: String = session_make_content(session_id, new_title, created_int, ts, old_folder) let new_content: String = session_make_content(session_id, new_title, created_int, ts, old_folder)
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]" let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
let new_id: String = engram_node_full( let new_id: String = wt_node(
new_content, "Conversation", "session:meta", new_content, "Conversation", "session:meta",
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
"Episodic", tags "Episodic", tags
+17
View File
@@ -657,6 +657,23 @@ if is_genesis && safe_to_seed {
} }
} }
// CRASH RECOVERY (neuron#117). Deltas the previous process staged but could not
// hand to the owner are still on disk the spool is a filesystem queue, not a
// memory buffer, precisely so that a soul that died mid-flight does not take its
// unpushed writes with it. Drain them before serving, so recovered memories are
// durable and recallable from the owner from the first request onward.
//
// Safe on a clean boot: an empty spool means no HTTP call at all. Safe in file
// mode: wt_drain returns immediately when ENGRAM_URL is unset.
let wt_recovered: Int = wt_drain()
if wt_recovered > 0 {
println("[soul] write-through: recovered " + int_to_str(wt_recovered)
+ " nodes from a previous process's spool -> persistence owner")
}
if wt_recovered < 0 {
println("[soul] write-through: spool present but the persistence owner is unreachable — queued, will retry on heartbeat")
}
println("[soul] serving on port " + int_to_str(port)) println("[soul] serving on port " + int_to_str(port))
http_serve_async(port, "handle_request") http_serve_async(port, "handle_request")
println("[soul] awareness loop starting") println("[soul] awareness loop starting")
+2 -2
View File
@@ -11,7 +11,7 @@ import "memory.el"
fn steward_log_event(kind: String, detail: String) -> Void { fn steward_log_event(kind: String, detail: String) -> Void {
let content: String = "STEWARD:" + kind + " | " + detail let content: String = "STEWARD:" + kind + " | " + detail
let tags: String = "[\"stewardship\",\"steward:" + kind + "\"]" let tags: String = "[\"stewardship\",\"steward:" + kind + "\"]"
let discard: String = engram_node_full( let discard: String = wt_node(
content, content,
"StewardshipEvent", "StewardshipEvent",
"steward:" + kind, "steward:" + kind,
@@ -221,7 +221,7 @@ fn steward_fingerprint_session(input: String, session_id: String) -> String {
+ " formality=" + fs_str + " formality=" + fs_str
+ " time=" + tb_str + " time=" + tb_str
let sample_tags: String = "[\"behavior\",\"BehaviorSample\",\"stewardship\"]" let sample_tags: String = "[\"behavior\",\"BehaviorSample\",\"stewardship\"]"
let discard: String = engram_node_full( let discard: String = wt_node(
sample_content, sample_content,
"BehaviorSample", "BehaviorSample",
"behavior:" + session_id, "behavior:" + session_id,
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# build-soul-from-dist.sh — build a deployable soul from the SAME input CI compiles.
#
# THE PROBLEM THIS CLOSES: until now, deploys were built by build-soul.sh, which
# compiles a scratch amalgam and never touches dist/soul.c. CI compiles dist/soul.c.
# Two lineages. On 2026-08-09 the committed input fell 2,761 bytes behind the sources
# while three binaries built the other way were installed on the operator machine —
# so "what runs" and "what the repo says builds" were different artifacts again,
# which is the whole of #133 and #111 wearing new clothes.
#
# This builds from dist/soul.c with CI's own flags, after asserting that dist/soul.c
# actually matches the .el sources, and writes a provenance sidecar so a deployer can
# refuse anything of unknown origin.
#
# -rdynamic and -DHAVE_CURL are copied from .gitea/workflows/ci.yaml deliberately.
# The CI comment explains -rdynamic: without it the runtime cannot resolve its HTTP
# handler by name via dlsym and the binary serves nothing on every route.
#
# usage: build-soul-from-dist.sh <out-binary>
set -u
OUT="${1:?usage: build-soul-from-dist.sh <out-binary>}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME="$ROOT/vendor/el-runtime/v1.0.0-20260501"
cd "$ROOT" || exit 2
echo "[build-from-dist] GATE: does dist/soul.c match the sources?"
if ! ./tools/soulc-stamp.sh --check; then
echo "[build-from-dist] REFUSING — the build input is stale. Regenerate and stamp first." >&2
exit 9
fi
[ -f "$RUNTIME/el_runtime.c" ] || { echo "pinned runtime missing at $RUNTIME" >&2; exit 2; }
echo "[build-from-dist] compiling dist/soul.c with CI's flags"
cc -O2 -DHAVE_CURL -rdynamic \
-I"$RUNTIME" \
dist/soul.c \
"$RUNTIME/el_runtime.c" \
-lcurl -lpthread -lm \
-o "$OUT" || { echo "[build-from-dist] COMPILE FAILED" >&2; exit 3; }
# Provenance sidecar: what a deployer checks before installing anything.
SRC_SHA="$(shasum -a 256 dist/soul.c | awk '{print $1}')"
STAMP_SHA="$(shasum -a 256 dist/soul.c.stamp | awk '{print $1}')"
COMMIT="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
DIRTY="clean"; [ -n "$(git status --porcelain -- '*.el' dist/soul.c 2>/dev/null)" ] && DIRTY="DIRTY"
cat > "$OUT.provenance" <<EOF
{"built_from":"dist/soul.c",
"dist_soul_c_sha256":"$SRC_SHA",
"stamp_sha256":"$STAMP_SHA",
"git_commit":"$COMMIT",
"worktree":"$DIRTY",
"runtime":"vendor/el-runtime/v1.0.0-20260501",
"flags":"-O2 -DHAVE_CURL -rdynamic"}
EOF
echo "[build-from-dist] OK -> $OUT ($(wc -c < "$OUT" | tr -d ' ') bytes)"
echo "[build-from-dist] provenance -> $OUT.provenance (commit ${COMMIT:0:8}, worktree $DIRTY)"
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# soulc-stamp.sh — make it impossible for dist/soul.c to drift from the sources
# in silence.
#
# THE PROBLEM (neuron#133, and its own words): "Nothing in the tree regenerates
# this file. Only a human running the recipe. It lags in batches, never
# per-change, and it will drift again."
#
# It drifted. On 2026-08-07 a CI or GKE build off main would have shipped an
# engine with NONE of five merged fixes — including a P0 safety fix — while
# main's source read as correct. CI compiles dist/soul.c, not the .el files, so
# the source being right is not the same as the build being right.
#
# WHY A STAMP AND NOT AUTO-REGENERATION: the CI workflow says elc cannot run on
# the runner ("elb on Linux would OOM the runner (elc uses 24GB+ virtual memory
# on a 16GB host)"). So the build cannot regenerate the file itself. What it CAN
# do, for free and with no compiler, is refuse to compile a stale one.
#
# The stamp records a fingerprint of every .el source that feeds the amalgam at
# the moment it was generated. --check recomputes and compares. Divergence is a
# build failure with the recipe in the message, not a silent ship.
#
# soulc-stamp.sh --write after regenerating dist/soul.c (records the fingerprint)
# soulc-stamp.sh --check in CI, before the compile (fails on drift)
set -u
MODE="${1:---check}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STAMP="$ROOT/dist/soul.c.stamp"
AMALGAM="$ROOT/dist/soul.c"
# Every .el at the repo root is an input to the amalgam. Sorted so the hash is
# order-independent; content-only so timestamps and checkouts do not perturb it.
fingerprint() {
(
cd "$ROOT" || exit 1
for f in $(ls -1 *.el 2>/dev/null | sort); do
printf '%s %s\n' "$(shasum -a 256 "$f" | awk '{print $1}')" "$f"
done
)
}
case "$MODE" in
--write)
[ -f "$AMALGAM" ] || { echo "no dist/soul.c to stamp — regenerate it first" >&2; exit 2; }
{
echo "# soul.c.stamp — fingerprint of the .el sources dist/soul.c was generated from."
echo "# Written by tools/soulc-stamp.sh --write. Do not hand-edit."
echo "# generated_amalgam_sha256 $(shasum -a 256 "$AMALGAM" | awk '{print $1}')"
echo "# generated_amalgam_bytes $(wc -c < "$AMALGAM" | tr -d ' ')"
fingerprint
} > "$STAMP"
echo "stamped $(fingerprint | wc -l | tr -d ' ') sources -> dist/soul.c.stamp"
;;
--check)
if [ ! -f "$STAMP" ]; then
echo "FAIL: dist/soul.c.stamp is missing — the build input is unverifiable." >&2
echo " Regenerate the amalgam, then: tools/soulc-stamp.sh --write" >&2
exit 1
fi
RECORDED="$(grep -v '^#' "$STAMP")"
CURRENT="$(fingerprint)"
if [ "$RECORDED" = "$CURRENT" ]; then
echo "soulc-stamp: OK — dist/soul.c matches the .el sources"
exit 0
fi
echo "FAIL: dist/soul.c is STALE. It does not match the current .el sources." >&2
echo "" >&2
echo "CI compiles dist/soul.c, not the .el files. Shipping this means shipping" >&2
echo "an engine that does not contain the merged source. That is neuron#133," >&2
echo "which once hid five merged fixes including a P0 safety fix." >&2
echo "" >&2
echo "Sources that changed since the amalgam was generated:" >&2
diff <(printf '%s\n' "$RECORDED") <(printf '%s\n' "$CURRENT") \
| grep -E '^[<>]' | awk '{print " " $1 " " $3}' | sort -u >&2
echo "" >&2
echo "Fix: regenerate the amalgam, then tools/soulc-stamp.sh --write" >&2
exit 1
;;
*)
echo "usage: soulc-stamp.sh [--check|--write]" >&2; exit 2 ;;
esac