From 921d081bcece8c5f2a9a3028136a901bce0e3007 Mon Sep 17 00:00:00 2001 From: Robert Sigmundsson Date: Fri, 17 Jul 2026 19:10:54 +0200 Subject: [PATCH 1/3] fix(surrealdb): delete_neuron cascades synapses brain-agnostically (audit DB-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delete_neuron deleted connected synapses with a 'brain_id = AND in/out = neuron' filter. Some write paths create synapses with a NULL brain_id, which that predicate silently skipped — orphaning them when the neuron was pruned. The schema's cascade_delete_synapses event does not catch these either: it does not fire on the SDK conn.delete() call. Match the neuron endpoint alone (still two index-friendly single-field DELETEs); a synapse pointing at a deleted neuron must go regardless of brain. Root-caused from live orphan synapses (7, all brain_id=NULL) found by the check-graph-integrity regression guard. Adds a mock-based unit test asserting the synapse-delete predicate is brain-agnostic + the delete error path. Tests: 2 new pass; 34 related unit tests pass; ruff clean. (cherry picked from commit 144c39da937dcd67d793b823c2670cbc980fef82) --- src/surreal_memory/storage/surrealdb/store.py | 27 +++---- tests/unit/test_delete_neuron_cascade.py | 81 +++++++++++++++++++ 2 files changed, 94 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_delete_neuron_cascade.py diff --git a/src/surreal_memory/storage/surrealdb/store.py b/src/surreal_memory/storage/surrealdb/store.py index 81016f7e..8e6abd79 100755 --- a/src/surreal_memory/storage/surrealdb/store.py +++ b/src/surreal_memory/storage/surrealdb/store.py @@ -883,22 +883,21 @@ async def update_neuron_embeddings(self, pairs: list[tuple[str, list[float]]]) - async def delete_neuron(self, neuron_id: str) -> bool: conn = self._ensure_conn() - brain_id = self._get_brain_id() - safe_brain = _safe_brain_id(brain_id) sid = _to_surreal_id(neuron_id) - # Delete connected synapses first (belt-and-braces: SurrealDB also auto-cleans - # edges when the neuron record is deleted). Endpoints are native in/out now. - # - # Measured live: a single "brain_id = $brain_id AND (in = ... OR out = ...)" - # query cost ~1.2s/call regardless of whether brain_id/the record id are - # inlined or param-bound — SurrealDB 3.2.0's planner doesn't use either - # `idx_synapse_in`/`idx_synapse_out` index across an OR of two different - # fields, so it falls back to a full scan. Splitting into two single-field - # DELETEs (each hits its own index) measured ~5ms total for both — this was - # the dominant cost behind prune's non-dry-run timeout on a large brain. - await self._query(f"DELETE synapse WHERE brain_id = '{safe_brain}' AND in = neuron:{sid}") - await self._query(f"DELETE synapse WHERE brain_id = '{safe_brain}' AND out = neuron:{sid}") + # Delete connected synapses first. This is brain-agnostic ON PURPOSE (audit + # DB-01): a synapse whose in/out points at this neuron becomes a dangling + # orphan once the neuron is gone, regardless of the synapse's own brain_id. + # Some write paths create synapses with a NULL brain_id, which the previous + # `brain_id = '' AND ...` filter silently skipped — leaving orphans that + # the schema's cascade_delete_synapses event did NOT catch either (that event + # does not fire on the SDK conn.delete() call below). Matching the neuron + # endpoint alone is correct AND index-friendly: two single-field DELETEs each + # hit their own `idx_synapse_in`/`idx_synapse_out` index (~5ms total); a + # combined OR across the two fields falls back to a full scan in SurrealDB + # 3.2.0's planner (~1.2s), which was the dominant cost behind prune timeouts. + await self._query(f"DELETE synapse WHERE in = neuron:{sid}") + await self._query(f"DELETE synapse WHERE out = neuron:{sid}") # Delete state (record id is state_, matching the writer in add_neuron) await self._query(f"DELETE neuron_state:state_{sid}") diff --git a/tests/unit/test_delete_neuron_cascade.py b/tests/unit/test_delete_neuron_cascade.py new file mode 100644 index 00000000..957648bb --- /dev/null +++ b/tests/unit/test_delete_neuron_cascade.py @@ -0,0 +1,81 @@ +"""Regression guard for audit finding DB-01: delete_neuron must remove ALL synapses +whose in/out points at the deleted neuron, regardless of the synapse's own brain_id. + +Some write paths create synapses with a NULL brain_id. The previous implementation +deleted synapses with a `brain_id = '' AND in/out = neuron` filter, which +silently skipped NULL-brain synapses — orphaning them when the neuron was pruned +(the schema's cascade_delete_synapses event does not fire on the SDK conn.delete()). +This test asserts the synapse-delete predicate is brain-agnostic (endpoint only). +""" + +from __future__ import annotations + +from typing import Any + +from surreal_memory.storage.surrealdb.store import SurrealDBStorage + + +class _FakeConn: + """Captures SDK conn.delete() record ids.""" + + def __init__(self) -> None: + self.deletes: list[str] = [] + + async def delete(self, record_id: str) -> None: + self.deletes.append(record_id) + + +class _FakeStore: + """Fake `self` exposing only what delete_neuron touches (no live DB, no full init).""" + + def __init__(self) -> None: + self._conn = _FakeConn() + self.queries: list[str] = [] + + def _ensure_conn(self) -> Any: + return self._conn + + def _get_brain_id(self) -> str: + return "uruboros" + + async def _query(self, sql: str, **params: Any) -> list[dict[str, Any]]: + self.queries.append(sql) + return [] + + async def _record_change_internal(self, *args: Any, **kwargs: Any) -> None: + return None + + +async def test_delete_neuron_deletes_synapses_brain_agnostically() -> None: + store = _FakeStore() + + ok = await SurrealDBStorage.delete_neuron(store, "00012d0f-c36e-4cd5-b2b4-7538fa683a17") + + assert ok is True + synapse_deletes = [q for q in store.queries if "DELETE synapse" in q] + + # Both endpoints are swept (in AND out), each as its own index-friendly single-field DELETE. + assert any("in = neuron:" in q for q in synapse_deletes) + assert any("out = neuron:" in q for q in synapse_deletes) + + # DB-01 regression guard: NO brain_id predicate — that filter skipped NULL-brain + # synapses and left dangling orphans. + assert all("brain_id" not in q for q in synapse_deletes), synapse_deletes + + # The neuron record itself is deleted via the SDK (underscore-normalised id). + sid = "00012d0f_c36e_4cd5_b2b4_7538fa683a17" + assert f"neuron:{sid}" in store._conn.deletes + + +async def test_delete_neuron_returns_false_when_delete_raises() -> None: + """Error path: a failing conn.delete() is swallowed and reported as False.""" + + class _BoomConn(_FakeConn): + async def delete(self, record_id: str) -> None: + raise RuntimeError("delete boom") + + store = _FakeStore() + store._conn = _BoomConn() + + ok = await SurrealDBStorage.delete_neuron(store, "abc-def") + assert ok is False From 81dca4472c2b8c3baba212f77a8585bfb1a639a4 Mon Sep 17 00:00:00 2001 From: Robert Sigmundsson Date: Sat, 18 Jul 2026 14:17:30 +0200 Subject: [PATCH 2/3] test(surrealdb): align delete_neuron cascade test with brain-agnostic shape (audit DB-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8e53b1f made delete_neuron cascade brain-agnostically (DELETE synapse WHERE in/out = neuron:, no brain_id filter) so NULL-brain_id orphan synapses are caught, but did not update the pre-existing upstream test test_delete_neuron_cascade_uses_in_out, which still asserted the old "brain_id = '' AND in = neuron:" shape. That test was already red on rebase/2.10.5 (prod); the rebase onto v2.12.1 inherited it faithfully. Update the assertions to the endpoint-only shape and add positive brain-agnostic checks (no brain_id in either DELETE) to lock in the audit DB-01 intent — otherwise a future change re-adding a brain_id filter would silently reintroduce the NULL-brain_id orphan-synapse leak. (cherry picked from commit 2a43020612ddf0319d2dd446e2b8878a3ec217b0) --- tests/unit/test_surrealdb_synapse_queries.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_surrealdb_synapse_queries.py b/tests/unit/test_surrealdb_synapse_queries.py index 3d6ddb80..dd625eb3 100644 --- a/tests/unit/test_surrealdb_synapse_queries.py +++ b/tests/unit/test_surrealdb_synapse_queries.py @@ -150,19 +150,30 @@ async def test_get_neighbors_inlines_endpoints_to_kill_n_plus_1(self): @pytest.mark.asyncio async def test_delete_neuron_cascade_uses_in_out(self): - """Two single-field DELETEs, not one OR query. + """Two single-field DELETEs, not one OR query — and brain-agnostic. A single "brain_id = ... AND (in = X OR out = X)" query measured ~1.2s/call live on SurrealDB 3.2.0 — the planner doesn't use either idx_synapse_in/idx_synapse_out across an OR of two different fields. Splitting into two single-field DELETEs (each hits its own index) measured ~5ms total. + + The DELETEs match the neuron endpoint ALONE, with no ``brain_id`` + filter (audit DB-01): a synapse pointing at the deleted neuron becomes + a dangling orphan regardless of its own brain_id, and some write paths + create synapses with a NULL brain_id that a ``brain_id = '' AND`` + filter silently skipped. Matching the endpoint alone is both correct and + index-friendly. """ st, conn = _store_with_mock_conn() await st.delete_neuron("a") - in_query = _find_query(conn, "AND in = neuron:") - out_query = _find_query(conn, "AND out = neuron:") + in_query = _find_query(conn, "WHERE in = neuron:") + out_query = _find_query(conn, "WHERE out = neuron:") assert in_query is not None assert out_query is not None assert " OR " not in in_query[0] assert " OR " not in out_query[0] + # brain-agnostic on purpose (audit DB-01): no brain_id filter, or the + # NULL-brain_id orphan synapses this fix targets would be skipped again. + assert "brain_id" not in in_query[0] + assert "brain_id" not in out_query[0] From 2d53a3da6ae8f9a7fffd9dca99c5a85368a13167 Mon Sep 17 00:00:00 2001 From: Robert Sigmundsson Date: Sun, 26 Jul 2026 12:13:23 +0200 Subject: [PATCH 3/3] fix(surrealdb): cleanup_ephemeral_neurons cascades synapses+state (audit DB-01 re-accrual) The 07-19 DB-01 fix (144c39d) made delete_neuron() cascade-delete synapses brain-agnostically, but cleanup_ephemeral_neurons() (smem_auto's session-end ephemeral sweep) had its own raw conn.delete() with no cascade at all, orphaning both synapses and neuron_state rows every time it ran. Route it through delete_neuron() instead of duplicating the delete logic. (cherry picked from commit dff01dd63af5ddac24e315c346516573b53553e2) --- src/surreal_memory/storage/surrealdb/store.py | 13 ++-- .../test_cleanup_ephemeral_neurons_cascade.py | 75 +++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_cleanup_ephemeral_neurons_cascade.py diff --git a/src/surreal_memory/storage/surrealdb/store.py b/src/surreal_memory/storage/surrealdb/store.py index 8e6abd79..5263cfb4 100755 --- a/src/surreal_memory/storage/surrealdb/store.py +++ b/src/surreal_memory/storage/surrealdb/store.py @@ -2594,13 +2594,14 @@ async def cleanup_ephemeral_neurons(self, max_age_hours: float = 24.0) -> int: ) count = 0 for r in rows: - # Re-sanitise the id read back from the DB rather than trusting the - # write-path invariant (defence in depth): _to_surreal_id strips the - # table prefix and folds, so ``neuron:{nid}`` stays a safe literal. - nid = _to_surreal_id(str(r.get("id", ""))) + # Route through delete_neuron() (audit DB-01) instead of a raw + # conn.delete(): that raw call skipped the synapse cascade AND the + # neuron_state cleanup, leaving both as orphans once the ephemeral + # neuron aged out — the same bug already fixed for delete_neuron's + # other callers, just missed here. try: - await self._conn.delete(f"neuron:{nid}") - count += 1 + if await self.delete_neuron(str(r.get("id", ""))): + count += 1 except Exception: pass return count diff --git a/tests/unit/test_cleanup_ephemeral_neurons_cascade.py b/tests/unit/test_cleanup_ephemeral_neurons_cascade.py new file mode 100644 index 00000000..e54d5755 --- /dev/null +++ b/tests/unit/test_cleanup_ephemeral_neurons_cascade.py @@ -0,0 +1,75 @@ +"""Regression guard for audit finding DB-01 (re-accrual, 2026-07-26): the +2026-07-19 fix (144c39d) made delete_neuron() cascade synapse deletes +brain-agnostically, but cleanup_ephemeral_neurons() used its own raw +conn.delete() with no cascade at all — orphaning both synapses and +neuron_state rows every time smem_auto's session-end ephemeral sweep ran. +This test asserts cleanup_ephemeral_neurons() now routes through +delete_neuron() so the cascade applies here too. +""" + +from __future__ import annotations + +from typing import Any + +from surreal_memory.storage.surrealdb.store import SurrealDBStorage + + +class _FakeConn: + """Captures SDK conn.delete() record ids.""" + + def __init__(self) -> None: + self.deletes: list[str] = [] + + async def delete(self, record_id: str) -> None: + self.deletes.append(record_id) + + +class _FakeStore: + """Fake `self` exposing only what cleanup_ephemeral_neurons + delete_neuron touch.""" + + def __init__(self, ephemeral_rows: list[dict[str, Any]]) -> None: + self._conn = _FakeConn() + self.queries: list[str] = [] + self._ephemeral_rows = ephemeral_rows + + def _ensure_conn(self) -> Any: + return self._conn + + def _get_brain_id(self) -> str: + return "uruboros" + + async def _query(self, sql: str, **params: Any) -> list[dict[str, Any]]: + self.queries.append(sql) + if "FROM neuron WHERE" in sql and "ephemeral" in sql: + return self._ephemeral_rows + return [] + + async def _record_change_internal(self, *args: Any, **kwargs: Any) -> None: + return None + + async def delete_neuron(self, neuron_id: str) -> bool: + return await SurrealDBStorage.delete_neuron(self, neuron_id) + + +async def test_cleanup_ephemeral_neurons_cascades_synapses_and_state() -> None: + store = _FakeStore(ephemeral_rows=[{"id": "neuron:00012d0f_c36e_4cd5_b2b4_7538fa683a17"}]) + + deleted = await SurrealDBStorage.cleanup_ephemeral_neurons(store, max_age_hours=24.0) + + assert deleted == 1 + synapse_deletes = [q for q in store.queries if "DELETE synapse" in q] + state_deletes = [q for q in store.queries if "DELETE neuron_state" in q] + + assert any("in = neuron:" in q for q in synapse_deletes) + assert any("out = neuron:" in q for q in synapse_deletes) + assert len(state_deletes) == 1 + assert store._conn.deletes == ["neuron:00012d0f_c36e_4cd5_b2b4_7538fa683a17"] + + +async def test_cleanup_ephemeral_neurons_skips_when_none_expired() -> None: + store = _FakeStore(ephemeral_rows=[]) + + deleted = await SurrealDBStorage.cleanup_ephemeral_neurons(store, max_age_hours=24.0) + + assert deleted == 0 + assert store._conn.deletes == []