Skip to content

neural_memory — Comprehensive Bug Audit Report

Auditor role: Lead auditor (isolated review context) Scope: neural_memory library — storage backends (SQLite, native Postgres, unified SQL dialect), sync engine, retrieval/recall engine, encode pipeline, MCP/server surface, core models, safety/crypto. Finding basis: 82 adversarially-confirmed findings (each with a concrete repro). No speculative items.


1. Executive Summary

This audit surfaced 82 confirmed defects. The dominant signal is a two-implementation drift problem: the project ships three storage backends — SQLite (sqlite_*.py), a native Postgres backend (storage/postgres/*), and a newer unified SQL dialect path (storage/sql/* + postgres_dialect.py) — and they silently disagree on physical schema, type coercion, FTS columns, and which model fields are persisted. A large fraction of HIGH/MEDIUM findings are not "this code is wrong in isolation" but "this backend does something different from the others, and the divergence loses data or crashes." The single CRITICAL is a true data-loss bug in the Merkle pull-sync path (new local memories are deleted before they are ever pushed).

Severity counts

Severity Count
CRITICAL 1
HIGH 15
MEDIUM 35
LOW 26
INFO 5
Total 82

Highest-impact items at a glance

  • CRITICAL #1 — Merkle pull-sync deletes new local entities never pushed → silent data loss for Pro users running full/pull sync.
  • HIGH #2–#5PostgresDialect.get_schema_ddl() text-mangles the SQLite DDL instead of using postgres_schema.py, producing a Postgres DB whose physical types (TEXT timestamps, INTEGER content_hash, no content_tsv) contradict the runtime's own assumptions. This single root cause spawns four distinct crash/overflow findings.
  • HIGH #12 / #13 — Sync engine marks local changes synced against the hub's ID space and discards the neural-aware merge result → permanent divergence and clobbered cross-device edits.
  • HIGH #9 / #10ReadPool.acquire() returns a pooled connection that callers close via async with, disabling all parallel reads after ≤3 calls.
  • HIGH #11 — InfinityDB batch retrieval path silently drops entity + keyword anchors → recall quality collapse on that backend.

2. Systemic Themes

These recurring root causes explain the majority of findings. Fixing the root cause closes a cluster of findings at once.

Theme A — Postgres DDL is generated by text-mangling the SQLite schema (the "type rot" root cause)

SQLStorage.initialize() calls PostgresDialect.get_schema_ddl(), which imports the SQLite SCHEMA string and applies two naive substitutions (INTEGER PRIMARY KEY AUTOINCREMENTSERIAL, strip PRAGMA). Everything else is emitted verbatim to Postgres. A correct hand-written postgres_schema.py exists but is never referenced by the dialect path. Consequences: - Timestamps stay TEXT instead of TIMESTAMPTZ (#2, #5). - content_hash stays 32-bit INTEGER, overflowing 64-bit SimHash (#4). - No content_tsv/summary_tsv generated columns or GIN indexes → every FTS query references a non-existent column (#2, #3). - serialize_dt() returns a datetime object that is type-correct only against TIMESTAMPTZ, so it crashes against the TEXT columns the same path creates (#5). Closing #2 (use postgres_schema.py DDL) resolves #3, #4, and #5 as well.

Theme B — Dialect parity: SQLite vs native-Postgres vs unified-dialect drift

Model fields and behaviors that work on one backend are silently absent on another. Native Postgres is a strict subset of the SQLite/dialect schema: - Fiber.essence dropped on native Postgres (#14). - Fiber.last_ghost_shown_at column exists but never written/read on native Postgres (#48). - Neuron.ephemeral flag dropped on native Postgres (#25); cache key also omits it (#63); SharedStorage.find_neurons drops the filter (#29). - Neuron lifecycle columns (last_accessed_at, lifecycle_state, frozen) absent from native Postgres (#49). - typed_memories.project_id FK present on SQLite, omitted on Postgres (#73). - JSON tag filter requires scalar on SQLite but JSON document on Postgres → crash (#6). - ILIKE wildcard escaping differs between branches (#20). - CAST(... AS DATE) collapses to year integer on SQLite (#23). - direction='both' arg-count mismatch on Postgres (#18). - bool/datetime/str binding mismatches against TIMESTAMPTZ (#19, #61).

Theme C — TIMESTAMPTZ / naive-UTC datetime handling on Postgres

All timestamps are naive UTC (utils/timeutils.py). The native Postgres pool never sets TimeZone=UTC and registers no datetime codec, so naive datetimes bound into TIMESTAMPTZ are interpreted in the server session timezone → every instant is offset on a non-UTC server (#15). Compounded by ISO-string vs datetime binding mismatches (#19, #61) and lexicographic string comparison of changed_at across naive vs tz-aware ISO (#42).

Theme D — Connection-pool lifecycle misuse

ReadPool.acquire() returns a bare long-lived aiosqlite.Connection, which IS an async context manager whose __aexit__ calls close(). Two callers use it as async with ... as db:, permanently closing pooled readers (#9, #10). The same pool has no in-use tracking, so concurrent readers alias connections and lose parallelism (#64). PostgresDialect.transaction() stashes the active connection on an instance attribute, unsafe under concurrent tasks (#17).

Theme E — Non-atomic multi-statement writes (auto-commit between DELETE and re-INSERT)

Several "replace" operations issue DELETE then INSERTs with no surrounding transaction; a partial failure leaves wiped/partial state: hot index (#54), fiber + junction rows (#55), native-Postgres import_brain dozens of independent writes (#60), and two whole-brain merge endpoints clear-before-import with a crash window (#31, #47).

Theme F — Broad except Exception swallowing real errors

Catch-all handlers assume the only failure mode is a deleted/pruned entity, silently discarding connection drops, deadlocks, type errors, and integrity violations: neuron-state updates (#21), maturation upsert masquerading as plain INSERT (#22), add_project reporting every failure as "already exists" (#59), and embedding/post-encode init (#65).

Theme G — Sync engine ID-space and merge correctness

Beyond the CRITICAL, the sync path conflates hub vs local ID spaces (#12), discards the computed neural-aware merge (#13), hashes fiber leaves over summary only (#43), caps bucket fetches at 10000 (#44), derives inserted IDs via MAX(id) (#57), and compares timestamps as strings (#42).

Theme H — Dead / mislabeled code (parameters & branches that do nothing)

A surprising number of advertised features are inert: retroactive entity linking reads promoted=0 rows after marking them promoted=1 (#37); PPR multi-anchor intersection can never fire (#16); supersession "is current superseded" check is a no-op loop (#77); find_transitive_closures(max_depth=...) is ignored (#81); row_to_neuron(row, dialect) silently drops the dialect (#53); base execute_returning_count() returns hardcoded 0 (#52).

Theme I — Frozen-dataclass immutable-update field drops

TypedMemory.verify() / extend_expiry() drop trust_score/source (#39); BrainConfig round-trip drops ~27 fields (#26); BrainModeConfig.to_dict→from_dict round-trips api_key to literal '***' (#69). Root cause: hand-written field-copying constructors instead of dataclasses.replace.

Theme J — MCP/server input handling

Per-call compact=false popped before handlers read it (#45); tool args not validated against schemas → tags="python" stored as 6 char tags (#72); rate limiter trusts spoofable X-Forwarded-For with unbounded dict growth (#46); WebSocket torn down by one malformed frame + unbounded _event_history growth (#71).


3. Full Findings

Grouped by severity, then category. Each entry: file:line, description, repro, fix sketch.


CRITICAL

#1 · logic · src/neural_memory/sync/sync_engine.py:362

Merkle pull-sync deletes new local entities that were never pushed (data loss) The Merkle sync path is pull-only: handle_merkle_sync (hub) only reads its DB and returns diffs; it never ingests the requesting device's new entities. On the client, process_merkle_response computes deletes as local_ids - remote_ids (lines 362–376) and calls _apply_remote_change with operation='delete' for every entity the hub doesn't have. Any entity created locally since the last sync (not yet pushed) is in local_ids but not remote_ids, so it gets deleted. The MCP sync handler runs the Merkle path for Pro users on action in ('full','pull') and returns immediately on success (sync_handler.py:138-144, 323) without pushing local changes first. A Pro device that adds memories then runs a full/pull sync silently destroys those new memories. Repro: Pro device + hub. Create N new neurons/fibers locally (in change_log, not pushed). Run nmem_sync action='full'. _try_merkle_sync posts buckets; hub returns diffs; process_merkle_response issues delete_neuron/delete_fiber for each new local ID. Data gone, never sent to hub. Fix: Merkle sync must not delete based on hub absence unless local changes are already pushed/acknowledged. Either (a) make the exchange bidirectional (hub ingests device-only entities / device pushes local-only IDs as inserts before diffing), or (b) restrict client-side deletes to IDs with synced=1 in change_log and treat unsynced-local IDs as pending-push. At minimum, run the change-log push before the Merkle reconcile rather than returning early.


HIGH

#2 · dialect-parity · src/neural_memory/storage/sql/postgres_dialect.py:318-336

PostgresDialect.get_schema_ddl() builds a broken Postgres schema by text-mangling the SQLite SCHEMA SQLStorage.initialize() (sql_storage.py:132-136) runs get_schema_ddl(), which imports the SQLite SCHEMA and only does two substitutions (INTEGER PRIMARY KEY AUTOINCREMENTSERIAL PRIMARY KEY, strip PRAGMA). Everything else is emitted verbatim: timestamps stay TEXT, JSON stays TEXT, content_hash stays INTEGER, booleans stay INTEGER, and NO content_tsv/summary_tsv generated columns or GIN indexes. A correct postgres_schema.py exists but is never referenced. Result: a Postgres DB whose physical types contradict the dialect's runtime assumptions. Repro: SQLStorage(PostgresDialect(...)).initialize() → tables with TEXT timestamps, no content_tsv. Any FTS call fails with column n.content_tsv does not exist; serialize_dt() binds a datetime into a TEXT column. Fix: Return the purpose-built DDL from neural_memory.storage.postgres.postgres_schema (same DDL used by legacy PostgreSQLStorage/ensure_schema). Delete the naive .replace()/re.sub() conversion. Closes #3, #4, #5.

#3 · dialect-parity · src/neural_memory/storage/sql/postgres_dialect.py:238-252

Postgres FTS queries reference content_tsv/summary_tsv columns the dialect schema never creates fts_neuron_query() emits n.content_tsv @@ plainto_tsquery('english', $N) and fts_fiber_query() emits f.summary_tsv @@ .... These tsvector columns exist only in postgres_schema.py, which get_schema_ddl() does not use. Every FTS search on the dialect-based Postgres backend raises an undefined-column error. Repro: On dialect Postgres, any recall hitting FTS → asyncpg UndefinedColumnError: column n.content_tsv does not exist. Fix: Once get_schema_ddl() emits the real postgres_schema DDL these resolve. Verify column names match exactly between postgres_schema.py and the query strings.

#4 · type-mismatch · src/neural_memory/storage/sql/postgres_dialect.py:328-336

content_hash stored as INTEGER (32-bit) overflows 64-bit SimHash neurons.content_hash holds a 64-bit SimHash (fine in SQLite where INTEGER is 64-bit; postgres_schema.py correctly uses BIGINT). The text-conversion leaves it INTEGER, which in Postgres is signed 32-bit (max 2,147,483,647). neurons.py binds content_hash directly (mixins/neurons.py:123,366). A SimHash > 2³¹ raises NumericValueOutOfRange, and idx_neurons_hash dedup is built on a truncated/failing column. Repro: Insert a neuron whose 64-bit SimHash exceeds 2³¹−1 → asyncpg.DataError / integer out of range. Near-dup detection never matches values that would collide under 64-bit. Fix: Use BIGINT (already correct in postgres_schema.py); fixing #2 resolves this.

#5 · type-mismatch · src/neural_memory/storage/sql/postgres_dialect.py:271-277

serialize_dt() binds tz-aware datetime into TEXT timestamp columns on dialect path serialize_dt() returns a Python datetime for asyncpg — correct only if the column is TIMESTAMPTZ. Because get_schema_ddl() leaves timestamp columns TEXT (#2), asyncpg receives a datetime to bind into a TEXT column → DataError. Every write through serialize_dt (neurons, fibers, synapses, typed_memory, etc.) is affected. Repro: On dialect Postgres, insert any row with a datetime field → asyncpg DataError: invalid input for type text / expected str got datetime. Fix: Emit TIMESTAMPTZ via postgres_schema.py (#2); then the datetime-object return is type-correct.

#6 · dialect-parity · src/neural_memory/storage/sql/mixins/fibers.py:253

Postgres JSON tag filter binds bare string to ::jsonb — invalid-json crash; sole filter in find_fibers_batch find_fibers (253) and find_fibers_batch (315) append the RAW tag string for d.json_array_contains("tags", n). On SQLite this expands to EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?) (scalar). On Postgres it expands to tags @> $N::jsonb (postgres_dialect.py:265); a bare tag like work is not valid JSON, so 'work'::jsonb raises invalid input syntax for type json. In find_fibers the Python post-filter masks results but the SQL crashes first; find_fibers_batch has NO post-filter, so the whole query errors. Repro: Postgres: find_fibers(tags={'work'}) or find_fibers_batch(neuron_ids=[...], tags={'work'}) → DataError at tags @> $N::jsonb. SQLite returns correct rows. Fix: Make json_array_contains parameter shape dialect-aware: Postgres binds json.dumps([tag]) (e.g. '["work"]'). Centralize so callers pass the logical value and the dialect wraps it. Add a Postgres integration test for tag filtering.

#7 · type-mismatch · src/neural_memory/storage/sql/mixins/projects.py:156

_dialect_row_to_project casts REAL priority via int(str(...)) — every project read raises ValueError projects.priority is REAL DEFAULT 1.0 and the model field is priority: float = 1.0. The row value is a Python float (e.g. 1.0). _dialect_row_to_project does priority=int(str(row["priority"]))int("1.0")ValueError. Crashes get_project, get_project_by_name, list_projects, and import_brain. The legacy mapper used priority=row["priority"] correctly — this is a regression. Repro: Create any project then get_project(pid)/list_projects()ValueError: invalid literal for int() with base 10: '1.0'. Fix: Use priority=float(row["priority"]) (matching the model and the export path which uses float()).

#8 · sql-correctness · src/neural_memory/storage/postgres/postgres_fibers.py:101-106

find_fibers time_overlaps maps window bounds to the wrong placeholders (inverted overlap test) The filter computes idx = len(params)+1, emits time_start <= $(idx+1) and time_end >= $idx, then params.extend([end, start]). That binds end$idx and start$(idx+1), so the predicate is time_start <= start AND time_end >= end — the inverse of an overlap test. Correct is time_start <= end AND time_end >= start (as in the SQL dialect mixin fibers.py:231-238). Repro: find_fibers(time_overlaps=(start, end)) on Postgres: a fiber fully inside the window is excluded; only fibers whose span contains [start,end] are returned. Fix: Swap placeholder indices to match values: time_start <= $idx (bound to end), time_end >= $(idx+1) (bound to start).

#9 · resource-leak · src/neural_memory/storage/sqlite_neurons.py:144

ReadPool connection closed by async with acquire() — pooled readers unusable after first use get_neuron_hashes() (144) and has_neuron_by_content_hash() (153) use async with self._read_pool.acquire() as db:. ReadPool.acquire() returns a long-lived shared aiosqlite.Connection, whose __aexit__ calls close(). Exiting the block permanently closes a pooled reader while the pool still references it. Round-robin later hands the same closed connection back → Cannot operate on a closed database. Pool size 3, so after ≤3 such calls every pooled read is broken. Repro: After the dedup/retrieval path calls these, the next read routed to the closed connection raises. With pool_size=3, three calls disable all parallel reads. Fix: Don't use acquire() as a context manager. Use db = self._read_pool.acquire() then async with db.execute(...) as cursor: (matching every other method). Or make acquire() return a wrapper whose __aexit__ is a no-op.

#10 · resource-leak · src/neural_memory/storage/read_pool.py:58

ReadPool.acquire() returns a connection that callers close via async with, destroying the pool acquire() returns a bare aiosqlite.Connection from a round-robin pool. The connection IS an async CM whose __aexit__ calls self.close(). Two consumers (sqlite_neurons.py:144,153) use it as a CM target, closing pooled connections on exit. After ≤pool_size (default 3) calls every reader is closed and never reopened. acquire() has no matching release/contextmanager contract, inviting the misuse. Repro: Call get_neuron_hashes() ~3× on a SQLiteStorage with ReadPool; connections close one-by-one, then later find_neurons()/get_stats() fail on a closed connection. Fix: Add @asynccontextmanager async def connection(self) that yields a pooled conn WITHOUT closing on exit; change callers to use it, or use the cursor context not the connection context. Never use the pooled Connection as an async with target.

#11 · logic · src/neural_memory/engine/retrieval.py:2536

InfinityDB batch path drops all entity + keyword anchors (recall regression) In _find_anchors_ranked, when the backend exposes find_neurons_by_content_batch (_has_batch True, line 2482), entity_anchors/keyword_anchors are populated (2501–2505), but the blocks that append them to anchor_sets/ranked_lists (2536–2551) are indented INSIDE the else: (standard/SQLite path, line 2506). On the batch backend the two primary content retrievers are computed then silently discarded — never entering spreading activation or RRF. Only time/embedding/BM25/fuzzy/graph anchors survive (graph fires only because it reads the local entity_anchors var at 2651). Repro: Query an InfinityDB-backed store with entities/keywords but no time hints → returned anchor_sets omit entity and keyword RankedAnchor lists; relevant neurons not activated, fibers missed. SQLite returns them correctly. Fix: De-indent 2536–2551 out of the else block so the if entity_anchors:/if keyword_anchors: appends run after both paths.

#12 · sql-correctness · src/neural_memory/sync/sync_engine.py:108

mark_synced() called with hub's sequence number against the local change_log ID space (data loss) process_sync_response calls self._storage.mark_synced(response.hub_sequence). hub_sequence is the hub's MAX(id) (handle_hub_sync:171-172). mark_synced runs UPDATE change_log SET synced=1 WHERE brain_id=? AND id <= ? against the LOCAL change_log — a separate ID space. The hub's max id (aggregating many devices) is far larger than any local id, so it marks ALL local pending changes synced, including ones created after prepare_sync_request snapshotted (limit 1000) and never sent. Those become synced=1 and are never pushed → lost. Repro: Local unsynced 1..50 sent; user adds 51..60; hub responds hub_sequence=10000; mark_synced(10000) sets 1..60 synced. 51..60 never transmitted but skipped by future get_unsynced_changes → permanent divergence. Fix: mark_synced should take the max LOCAL change id actually included in the request (max(c.sequence for c in request.changes)). Keep update_device_sync(device_id, hub_sequence) for the hub-space watermark; separate local "synced up to" from remote "pulled up to".

#13 · logic · src/neural_memory/sync/sync_engine.py:146

Neural-aware conflict merge is computed then discarded on the hub — conflicting cross-device edits lose data handle_hub_sync calls merge_change_lists(...) but assigns the merged result to _ (146), keeping only conflicts_list. It then applies incoming changes raw (151–168) via _apply_remote_change, reconstructing the entity purely from the incoming payload with no field-level merge. The neural-aware resolution in incremental_merge._merge_payloads (weight=max, reinforced_count/frequency=sum, tags=union, delete-wins, PREFER_STRONGER) is never written. Conflicts resolved by raw last-applied-wins. Repro: A adds tag t1 (weight→0.9); B sets weight 0.4. Both sync. Hub computes merged winner (0.9, union) but discards it, applies last-in-list raw → A's weight/tags clobbered. Reported conflict claims merged; storage holds the loser. Fix: Apply the merged change list (iterate merged winners through _apply_remote_change) instead of request.changes; fold remote_by_entity winners so both sides union per the neural rules.

#14 · dialect-parity · src/neural_memory/storage/postgres/postgres_schema.py:106

Fiber.essence silently dropped on the native Postgres backend (no column, no write, no read) The Fiber model carries essence (single-sentence distillation for fidelity-layer recall). On SQLite it's a real column (migration 33→34), written/read and handled by the dialect path. The native Postgres fibers table has no essence column, the migration list never adds one, add_fiber never writes it, row_to_fiber never reads it. Every fiber stored through native Postgres loses essence permanently and reads back None. Repro: backend=postgres; store a fiber with essence set; get_fiberFiber.essence is None. Fidelity-layer essence rendering is dead on Postgres. Fix: Add essence TEXT to the native Postgres fibers DDL + ALTER TABLE fibers ADD COLUMN IF NOT EXISTS essence TEXT; include essence in add_fiber/update_fiber and pass essence=record['essence'] in row_to_fiber.

#15 · type-mismatch · src/neural_memory/storage/postgres/postgres_store.py:98

Naive UTC datetimes bound to TIMESTAMPTZ interpreted in server session timezone (no UTC pinning / no tz codec) All timestamps are naive UTC (utils/timeutils.py). The native Postgres backend binds naive datetimes directly into TIMESTAMPTZ columns across add_neuron, neuron_states, add_fiber, add_typed_memory, save_brain. asyncpg encodes a naive datetime against TIMESTAMPTZ using the connection's TimeZone GUC, but the pool never sets server_settings={'TimeZone':'UTC'} and registers no codec. On any non-UTC server every written instant is offset; readback via _to_naive_utc is correct only when the session tz is UTC. SQLite stores the literal naive ISO string (no shift) → backends disagree on the actual stored instant. Repro: Native Postgres with SET TimeZone='America/New_York'; add_neuroncreated_at shifted relative to SQLite. Time-range queries return wrong rows across backends. Fix: Pin the pool to UTC via create_pool(server_settings={'TimeZone':'UTC'}) and/or convert naive utcnow() to tz-aware UTC before binding. Same fix needed for the dialect PostgresDialect pool.

#16 · logic · src/neural_memory/engine/ppr_activation.py:283

PPR multi-anchor intersection is dead — can never identify cross-set neurons activate_from_multiple() flattens all anchor sets into one PPR seed set, then checks result.source_anchor against each set: if result.source_anchor in anchor_set: set_membership[nid].add(set_idx). But source_anchor is a SINGLE seed id (nearest seed, line 184), so every reached neuron belongs to at most one set. The len(sets) > 1 condition (293) is never satisfiable. The multi-constraint intersection boost (working in BFS SpreadingActivation) returns empty whenever PPR is active. Repro: PPR engine; recall with ≥2 anchor sets → intersections=[] even when neurons are reachable from both. Fix: Track per-neuron seed-set provenance as a set (propagate union of contributing seeds' set indices along push edges). Or run PPR once per anchor set and intersect result keys like SpreadingActivation.


MEDIUM

#17 · async-concurrency · src/neural_memory/storage/sql/postgres_dialect.py:292-312

transaction() uses instance attribute _txn_conn — not safe under concurrent tasks transaction() stashes the active connection on self._txn_conn so execute()/fetch_*() route to it. The comment asserts single-writer safety, but this is shared mutable state: two coroutines entering transaction() concurrently (or a read while a txn is open) overwrite _txn_conn, leaking queries across connections. The pool (max_size 10) enables concurrency. Nested txns are partially handled; interleaved siblings are not. Repro: Two concurrent transaction() blocks (gather of two writers): writer A's execute() may run on B's connection after B sets _txn_conn → cross-transaction writes / "another command is already in progress". Fix: Use contextvars.ContextVar for the txn connection, or pass the connection explicitly through the call stack.

#18 · dialect-parity · src/neural_memory/storage/sql/mixins/synapses.py:283

get_synapses_for_neurons direction='both' passes too many params on Postgres (arg-count mismatch) in_clause is built once (283) and embedded twice: (source_id {in_clause} OR target_id {in_clause}). SQLite: 2N ? placeholders, code supplies [brain_id, *in_params, *in_params]=2N+1 — correct. Postgres: = ANY($2) (one array), SQL references only $1,$2, but code passes 3 args. asyncpg requires arg count to match highest placeholder ($2 ⇒ 2 args) → error. Repro: Postgres get_synapses_for_neurons(['n1','n2'], direction='both') → "the server expects 2 arguments, 3 were passed". Works on SQLite. Fix: For Postgres reuse the single array param: [brain_id, *in_params] ($2 can be referenced twice). Branch on dialect, or have in_clause report how many params it consumed.

#19 · type-mismatch · src/neural_memory/storage/sql/mixins/typed_memory.py:479

promote_memory_type binds bare ISO string to TIMESTAMPTZ expires_at on Postgres Accepts new_expires_at: str | None and binds it directly (475/479). Every other write serializes via d.serialize_dt(...) (datetime object on Postgres). expires_at is TIMESTAMPTZ; asyncpg won't coerce str → DataError. SQLite (TEXT) silently accepts it. Repro: Postgres promote_memory_type(fiber_id, ..., new_expires_at='2027-01-01T00:00:00+00:00') → DataError. Works on SQLite. Fix: Parse to datetime (or accept datetime|None) and pass d.serialize_dt(parsed), matching add_typed_memory/update_typed_memory.

#20 · dialect-parity · src/neural_memory/storage/sql/mixins/neurons.py:610

suggest_neurons Postgres ILIKE branch does not escape LIKE wildcards in user prefix Postgres ILIKE builds f"{prefix}%" with no escaping and no ESCAPE clause (608–610). The SQLite fallback escapes \, %, _ and uses ESCAPE '\\'. A prefix with %/_ is literal on SQLite but a wildcard on Postgres — divergent matching (not injection; parameterized). Repro: Postgres suggest_neurons('user_') matches userX... (underscore=any) instead of literal user_. Fix: Escape %, _, \ in the Postgres branch and append ESCAPE '\\', mirroring the SQLite fallback.

#21 · error-handling · src/neural_memory/storage/sql/mixins/neurons.py:493

update_neuron_state / batch swallow all exceptions — silent data loss on real DB errors update_neuron_state (493) and update_neuron_states_batch (542) wrap the upsert in except Exception + logger.debug, assuming the only failure is a pruned neuron (FK violation). This also swallows connection drops, serialization failures, type errors, lock timeouts. State updates (activation_level, access_frequency, last_activated) are silently dropped, corrupting decay/homeostasis. The INSERT...ON CONFLICT is unlikely to even hit the assumed FK error. Repro: Transient Postgres error during update_neuron_states_batch → all state updates silently discarded; caller sees success. Fix: Catch only the specific integrity/FK exception (asyncpg.ForeignKeyViolationError/sqlite3.IntegrityError) and re-raise everything else.

#22 · sql-correctness · src/neural_memory/storage/sql/mixins/maturation.py:23

save_maturation uses plain INSERT for an upsert PK, silently swallowing IntegrityError on update Documented as "Save or update" but issues a bare INSERT into memory_maturations (PK (brain_id, fiber_id)). The second save raises UNIQUE/IntegrityError, swallowed and logged as "Skipping maturation save for deleted fiber" — but the fiber isn't deleted. The intended update (stage advance, rehearsal_count++, reinforcement_timestamps append) is lost. lifecycle.py:402-405 and consolidation.py:1320 updates silently dropped → EPISODIC→SEMANTIC transition can never occur. Repro: save_maturation for F (insert), then again with advanced stage → IntegrityError swallowed; DB holds old stage. Fix: Use d.upsert_sql('memory_maturations', [...], conflict=['brain_id','fiber_id'], update=['stage','stage_entered_at','rehearsal_count','reinforcement_timestamps']); keep try/except only for genuine FK (deleted fiber), narrowed.

#23 · dialect-parity · src/neural_memory/storage/sql/mixins/tool_events.py:230

get_tool_stats_by_period CAST(created_at AS DATE) collapses daily buckets to the year integer on SQLite created_at is stored as ISO TEXT on SQLite. SQLite has no DATE type; CAST('2026-06-23T10:30:00+00:00' AS DATE) applies NUMERIC affinity → integer 2026. GROUP BY day groups every event into one per-year bucket; the returned date field is the integer year. The "aggregated by day" contract is broken on SQLite; works on Postgres (TIMESTAMPTZ) — a divergence. Repro: Insert events across multiple days in 2026 on SQLite; get_tool_stats_by_period(days=30) → all collapse into one bucket date=2026. Fix: Portable day-prefix: SUBSTR(created_at, 1, 10) for SQLite, or a dialect.date_trunc_day() helper (SUBSTR(...) SQLite, created_at::date/date_trunc('day',...) Postgres).

#24 · type-mismatch · src/neural_memory/storage/postgres/postgres_neurons.py:38-50

Embedding list bound to pgvector column with no codec/registration — add_neuron raises whenever an embedding is present add_neuron binds neuron.metadata['_embedding'] (list[float]) directly to the embedding vector(N) column ($8) with no ::vector cast and no codec. The pool never calls register_vector/set_type_codec. asyncpg cannot encode a list to the custom vector type → INSERT raises. update_neuron (187) has the same bug. Query-side find_neurons_by_embedding uses $1::vector but is wrapped in try/except returning [], so search silently degrades; the insert is unguarded and propagates. The backend cannot persist embeddings despite advertising pgvector cosine search. Repro: Attach metadata['_embedding']=[0.1]*384, add_neuron(neuron) → asyncpg raises on encode. Fix: Register the vector codec on each pooled connection (pgvector.asyncpg.register_vector via create_pool(init=...)) OR serialize to pgvector text form '[v1,v2,...]' and bind with explicit $8::vector in both add/update.

#25 · dialect-parity · src/neural_memory/storage/postgres/postgres_schema.py:36-53

ephemeral neuron flag silently dropped on the Postgres backend (no column, no insert, no filter) The Neuron model has ephemeral: bool for auto-expiring session-scoped memories. The Postgres neurons DDL has no ephemeral column; add_neuron never writes it; row_to_neuron never reads it; find_neurons accepts an ephemeral param but never filters on it. The unified dialect backend stores and filters it. Result: ephemeral=True memories become permanent and are never excluded. Repro: add_neuron(Neuron(..., ephemeral=True)); later find_neurons(ephemeral=False) on Postgres still returns it, and it never expires. Fix: Add ephemeral BOOLEAN DEFAULT FALSE (+ ALTER ... ADD COLUMN IF NOT EXISTS), include in add/update, read in row_to_neuron, honor the param in find_neurons WHERE.

#26 · logic · src/neural_memory/storage/postgres/postgres_row_mappers.py:156-173

row_to_brain silently drops most BrainConfig fields on read, reverting them to defaults save_brain serializes the full config via asdict(brain.config) (~40 fields). row_to_brain reconstructs from only ~13 listed keys, ignoring the rest (activation_strategy, ppr_damping, learning_rate, lateral_inhibition_k, compression_enabled, graph_expansion_, sigmoid_steepness, etc.). A save→get round-trip loses non-default values. Also uses config_data.get('embedding_enabled', False) while the dataclass default is True → legacy rows missing the key get embeddings disabled. Repro: Save Brain with activation_strategy='ppr', learning_rate=0.2; get_brain → defaults 'classic'/0.05. Fix:* Reconstruct generically: BrainConfig(**{k:v for k,v in config_data.items() if k in {f.name for f in dataclasses.fields(BrainConfig)}}); fix the embedding_enabled default to True.

#27 · sql-correctness · src/neural_memory/storage/sqlite_fibers.py:392

list_pinned_fibers SELECTs non-existent type/priority columns → OperationalError, MCP 'pin list' 500s The query references type and priority on fibers, which has neither (they live on typed_memories). SQLite raises OperationalError: no such column: type. The caller train_handler.py:180 does NOT wrap this in try/except, so nmem_pin list surfaces a hard 500. Repro: nmem_pin action='list' against SQLite with any pinned fiber (or even zero rows — resolved at prepare time) → no such column: type propagates. Fix: SELECT id, summary, tags, created_at FROM fibers WHERE brain_id=? AND pinned=1 (or JOIN typed_memories); remove the row[2]/row[3] mappings and reindex.

#28 · sql-correctness · src/neural_memory/storage/sqlite_fibers.py:455

get_fiber_stage_counts queries non-existent stage column → consolidation_ratio silently always 0.0 SELECT stage, COUNT(*) FROM fibers ... GROUP BY stage references a stage column not on fibers (it lives on memory_maturations.stage) → OperationalError. The caller (maintenance_handler.py:163-167) swallows it at debug, leaving consolidation_ratio = 0.0. The health-pulse "semantic consolidation" metric is permanently 0 on SQLite; any alert keyed on it is dead. Repro: Run the maintenance/health-pulse handler on SQLite → exception swallowed, consolidation_ratio stays 0.0. Fix: SELECT mm.stage, COUNT(*) FROM memory_maturations mm WHERE mm.brain_id=? GROUP BY mm.stage.

#29 · api-consistency · src/neural_memory/storage/shared_store.py:192

SharedStorage.find_neurons silently drops ephemeral and created_before filters find_neurons accepts ephemeral and created_before (documented in base.py:164-178) but the body (204–216) never forwards them to the HTTP params (only type, content_contains, content_exact, time_range, limit, offset). A caller filtering ephemeral=False or doing a created_before time-travel query against a SHARED/HYBRID-remote brain gets UNFILTERED results — ephemeral scratch leaks, future-dated neurons returned. Fails open. Repro: find_neurons(content_exact='x', ephemeral=False) returns ephemeral neurons; find_neurons(created_before=past_dt) ignores the cutoff. Fix: Add if ephemeral is not None: params['ephemeral']=ephemeral and if created_before is not None: params['created_before']=created_before.isoformat(), or raise NotImplementedError if the remote API cannot filter.

#30 · api-consistency · src/neural_memory/storage/factory.py:162

HybridStorage is not a NeuralStorage and is missing batch/extended/brain_id surface create_storage() returns HybridStorage for BrainMode.HYBRID via return hybrid_storage # type: ignore[return-value], declaring NeuralStorage. But HybridStorage doesn't inherit from NeuralStorage/CoreStorage and defines only ~30 delegating methods. Missing: brain_id/current_brain_id properties, get_neurons_batch, get_neuron_states_batch, update_neuron_states_batch, batch_save/disable_auto_save, find_neurons_exact_batch, and ALL ExtendedStorage methods (typed memory, change log, devices, merkle, alerts, sources). Any caller touching those hits AttributeError. The type: ignore suppresses the static signal. Repro: s=await create_storage(hybrid_config,'b1'); s.brain_id → AttributeError; or s.get_neurons_batch([...]) during recall → AttributeError. Fix: Make HybridStorage subclass NeuralStorage so missing abstracts surface at instantiation; add brain_id/current_brain_id properties; override missing methods to delegate to self._local. Remove the type: ignore.

#31 · error-handling · src/neural_memory/storage/factory.py:427

HybridStorage.sync() clears local before re-importing merged snapshot — data-loss window on failure sync() does await self._local.clear(self._brain_id) (427) then await self._local.import_brain(merged_snapshot, ...) (428). If import_brain raises (serialization error, disk full, partial write), local is already wiped with no rollback. The merged snapshot exists only in memory. Repro: Trigger a sync where import_brain fails after clear → local DB empty for that brain. Fix: Import into a temp/staging brain id (or wrap clear+import in one transaction) and swap only after success; on failure abort without clearing. At minimum snapshot local to a recovery file before clear.

#32 · logic · src/neural_memory/engine/retrieval.py:997

Retriever-contribution signal uses arbitrary set element instead of fiber anchor _result_fiber_anchor_ids = {next(iter(f.neuron_ids)) for f in fibers_matched if f.neuron_ids}. Fiber.neuron_ids is a set[str], so next(iter(...)) returns an arbitrary, non-deterministic element, not the anchor. This set is compared against each ranked list's anchor neuron_ids (1035) to decide whether a retriever contributed, persisted via save_retriever_outcome and feeding per-brain retriever weights for RRF. A random set member rarely equals the surfaced anchor → contribution signal mostly wrong, corrupting calibration. The correct anchor is f.anchor_neuron_id (used correctly at 1108, 3015). Repro: Inspect persisted retriever outcomes: retrievers that surfaced the winning anchor are often logged contributed=False. Weights drift to noise. Fix: {f.anchor_neuron_id for f in fibers_matched if f.anchor_neuron_id}.

#33 · logic · src/neural_memory/engine/retrieval.py:2096

Supersession: outdated neuron left un-demoted when latest already visited In _apply_causal_semantics, the per-edge loop does if current in chain_visited: continue (current=latest_id) at 2096–2097 BEFORE demoting outdated_id and recording supersession_map[outdated_id]=ultimate_latest (2153–2169). chain_visited is meant to avoid re-walking a resolved chain, but skipping the whole edge also skips demoting this edge's distinct outdated_id. When two edges converge on the same latest node, the second edge's superseded neuron is neither demoted to ghost level nor recorded. Repro: A and B both SUPERSEDED_BY C, all activated. First edge marks C visited; the second hits continue and its outdated neuron stays at unsuppressed activation. Fix: Perform outdated_id demotion + supersession_map recording per-edge regardless of dedup; gate only the expensive while chain-walk (and latest-boost) on current in chain_visited. Or memoize latest→ultimate_latest and reuse for skipped edges.

#34 · logic · src/neural_memory/engine/causal_traversal.py:176

Causal chain total_weight multiplies ALL discovered nodes (BFS fan-out), not a single chain trace_causal_chain BFS collects every reachable causal neighbor into a flat steps list (a tree, not a path). total_weight = math.prod(s.weight for s in steps) multiplies ALL discovered nodes' weights. The docstring describes it as "chain confidence" implying one chain. Each weight ≤1.0, so the product decays exponentially with the NUMBER of nodes regardless of individual link strength — more/stronger evidence → lower confidence (inverted). Repro: 10 direct neighbors each weight 0.9 → 0.9**10 ≈ 0.35; 30 → ≈0.04. Confidence collapses as breadth grows. Fix: Either (a) reconstruct a single path (parent pointers, multiply along the chosen path), or (b) redefine as a per-step mean / normalized geometric mean over depth, and update the docstring.

#35 · logic · src/neural_memory/engine/drift_detection.py:379

detect_temporal_drift emits Cartesian-product false positives; never checks co-occurrence as documented Docstring/comment say it finds a replacement "that co-occurs with old_term in co-occurrence matrix." The implementation never consults co-occurrence data — it pairs every disappeared old_term (count≥2) with every appeared new_term (count≥2) and emits a drift whenever min/max ratio ≥0.3. With D disappeared × N new terms, up to DN spurious mappings of unrelated topics. Repro: Early {auth:3, redis:2}, recent {billing:3, k8s:2} → emits auth→billing, auth→k8s, redis→billing, redis→k8s with no relationship. Fix:* Actually use a co-occurrence matrix (pair only if they co-occurred in overlapping sessions/fibers, require a minimum co-occurrence count). Or drop the "co-occurs" claim and use a far stricter signal.

#36 · logic · src/neural_memory/engine/drift_detection.py:231

wasserstein_1 treats unordered activation lists as positional histograms — order-dependent, false drift Fed dict[str, list[float]] of raw per-neuron activation LEVELS. It L1-normalizes and computes a CDF difference treating list INDEX as the distribution bin. These lists are unordered collections, not histograms — index carries no meaning. The result depends on arbitrary ordering; identical multisets in different order report nonzero W1 → false "drifting"/"major_shift" (≥0.3/≥0.6). Repro: wasserstein_1([0.1,0.9],[0.9,0.1]) returns 0.4. Same multiset reordered flagged drifting; re-running with different DB row order changes the result. Fix: Bin values into a fixed shared histogram (quantize [0,1] into K buckets, count per bucket, compute W1 over ordered buckets). At minimum sort both lists first.

#37 · logic · src/neural_memory/engine/pipeline_steps.py:1142

Retroactive entity linking is dead code — refs marked promoted before the link step reads them In ExtractEntityNeuronsStep, when an entity crosses the promotion threshold a neuron is created then storage.mark_entity_refs_promoted(entity.text) (270) sets promoted=1 for all its refs. Later CreateSynapsesStep._retroactive_entity_link calls get_entity_ref_fiber_ids(entity_neuron.content) (1142), which returns rows WHERE promoted = 0. Since refs were just marked promoted=1, the query always returns [] → no retroactive INVOLVES synapses are ever created. Repro: Mention 'PostgreSQL' (deferred ref promoted=0), mention again → entity neuron created, refs marked promoted=1; _retroactive_entity_link gets []; anchor never linked. The existing unit test mocks the call to return [], so it never catches this. Fix: Capture prior anchor IDs BEFORE marking promoted (stash into ctx then consume), OR add an include_promoted/since param and mark promoted AFTER linking, OR move mark_entity_refs_promoted into CreateSynapsesStep after the link.

#38 · logic · src/neural_memory/engine/pipeline_steps.py:278

_find_similar_entity dedups across neuron types — entities collapse into pre-existing concepts _find_similar_entity calls storage.find_neurons(content_exact=text, limit=1) with NO type filter (likewise the content_contains/simhash fallbacks). Concept neurons share surface forms with entities (e.g. 'python'). When an entity's text equals an existing CONCEPT neuron's content, it returns that concept; the step treats it as existing and never creates an ENTITY neuron, missing entity-specific wiring (INVOLVES weighting, CO_OCCURS edges, CrossMemoryLinkStep). Parity gap vs ExtractConceptNeuronsStep, which passes type=NeuronType.CONCEPT. Repro: Encode 'I use python a lot' (python→CONCEPT), then 'Alice wrote python at Google' → 'python' resolves to the concept, no entity neuron, cross-memory/co-occurrence linking skipped. Fix: Thread the expected entity neuron type (_entity_type_to_neuron_type(entity.type)) into the find_neurons calls inside _find_similar_entity (or at least exclude CONCEPT).

#39 · logic · src/neural_memory/core/memory_types.py:335

TypedMemory.verify() and extend_expiry() silently drop trust_score and source fields TypedMemory is a frozen dataclass; immutable-update methods must carry over all fields. verify() (335–348) and extend_expiry() (350–364) construct a new instance WITHOUT trust_score= or source=, so both revert to defaults (None). with_priority()/with_tier() preserve them correctly. Verifying is routine, so provenance trust score and source are silently lost on the most common transition. Repro: tm = TypedMemory.create(..., source='user_input', trust_score=0.9); tm2 = tm.verify()tm2.trust_score is None, tm2.source is None. Fix: Add trust_score=self.trust_score, source=self.source in both. Better: use dataclasses.replace(self, ...) everywhere so no field can be dropped.

#40 · logic · src/neural_memory/surface/generator.py:279

Surface forward edge references are always lost (incremental entries lookup) In _extract_graph, each edge's internal target id resolves via self._find_assigned_id(syn.target_id, entries), which only scans entries ALREADY appended (entries.append at 296, end of each iteration). Iterating in score order, any edge whose target is scored LOWER than its source has no entry yet → target_id_ref is None. The edge degrades to text-only, losing the graph cross-link. Roughly half of all intra-surface edges (every forward reference) are affected. Repro: Neuron A (higher score) has edge A→B, B also top-N but lower → serialized edge renders →type→ "B content" with no [id] backref. Fix: Two passes: assign all surface node IDs first (build neuron_id→node_id map), then build edges so both forward and backward references resolve.

#41 · api-consistency · src/neural_memory/surface/generator.py:356

Cluster node_ids hold raw neuron UUIDs, never surface node IDs; trim cleanup is a silent no-op _build_clusters sets Cluster.node_ids = tuple(sorted(component)[:5]) where component holds raw neuron UUIDs. Everywhere else node_ids/target_id reference short surface ids ('c1','d2'). Consequences: (1) token_budget._trim_lowest_priority_graph removes a graph entry by surface id and scrubs it from cluster.node_ids — but cluster ids are UUIDs, so nid != removed_id never matches → dangling cluster refs. (2) The serialized CLUSTERS section emits UUIDs instead of short ids, breaking the "use clusters to scope recall" contract and inflating the token estimate. Repro: Generate a surface with a cluster, trim below budget so the node is dropped; the cluster still lists the UUID, serialize() emits @topic: [<uuid>, ...]. Fix: Map each clustered neuron UUID to its assigned surface node id (reuse the graph-pass map) before constructing Cluster.node_ids, dropping neurons not in the graph.

#42 · type-mismatch · src/neural_memory/sync/incremental_merge.py:85

PREFER_RECENT / merge_change_lists compare changed_at as strings — breaks across naive vs tz-aware ISO, silent SyncChange.changed_at is typed str. _pick_winner (85) and merge_change_lists (176) compare with >=/> lexicographically — correct only when every timestamp is fixed-width, same-tz ISO. Sources mix: changelog uses naive isoformat(); merkle builds changed_at=payload.get('updated_at', utcnow().isoformat()) where DB updated_at can be '' or differently formatted; remote payloads arrive over JSON. A '' or '+00:00'-suffixed string sorts incorrectly vs naive ISO → wrong winner, no error. Repro: Local '2026-06-01T09:00:00' (naive) vs remote '2026-06-01T09:00:00+00:00' (same instant) → longer tz string sorts greater, remote wins; differing offsets can invert ordering. Fix: Parse to normalized naive-UTC datetime (ensure_naive_utc + fromisoformat) before comparing, with try/except fallback. Better: store changed_at as a real datetime or guarantee one canonical ISO format at all ingestion points.

#43 · logic · src/neural_memory/storage/sqlite_merkle.py:290

Fiber Merkle leaf hashes only over summary — non-summary fiber edits never sync via Merkle _fetch_entities builds fiber leaves from (id, updated_at, COALESCE(summary,'')) because fibers have no content_hash. But fiber writes never SET updated_at (stays at the schema default ''), so the only mutable component is summary. Any change not altering summary — tags (auto_tags/agent_tags), salience, conductivity, frequency, neuron_ids/synapse_ids — leaves the leaf hash unchanged, so the Merkle diff skips it. Repro: Pro device reinforces a fiber: salience/frequency bump, auto_tags grow, summary unchanged → bucket hash unchanged → device B never pulls the update; fiber stays stale. Fix: Maintain fibers.updated_at on every write, OR include a content-fingerprint of mutable fields (salience, tags, conductivity, member sets) in the leaf input.

#44 · logic · src/neural_memory/sync/sync_engine.py:403

_fetch_bucket_entities silently caps at limit=10000 — buckets drop entities on large brains, skewing insert/delete sets Fetches all entities with find_neurons(limit=10000)/find_fibers(limit=10000)/get_synapses() then filters in Python by id[:2]==bucket_key. On a brain with >10000 neurons/fibers, entities beyond the limit are never returned → absent from local_entities in process_merkle_response. Present local entities look missing (skewing local_ids), updates for truncated entities are dropped, delete/insert detection runs against an incomplete view. Repro: 15000 neurons → find_neurons(limit=10000) returns 10000; ~5000 never seen; local_ids is truncated. Fix: Query the bucket directly with a SQL prefix filter (get_bucket_entity_ids using LOWER(SUBSTR(id,1,2))=?) and a paginated/unbounded per-bucket fetch.

#45 · api-consistency · src/neural_memory/mcp/response_compactor.py:170

Per-call compact=false silently ignored by nmem_remember and nmem_recall server.py:554 calls should_compact(tool_args=...) BEFORE dispatch. should_compact() does per_call = tool_args.pop("compact", None) — POPS the key. Handlers _remember/_recall then read bool(args.get("compact", True)), always getting the default True. A client passing compact=false still receives the early-return compact response, denied the enriched payload (thought_chains, layers, score_breakdown, related_memories, dedup hints). The server-level compact_response() is correctly skipped but the handler's own compact branch fires anyway. Repro: nmem_recall {query:'x', compact:false, include_paths:true} → expected rich response; actual: should_compact pops 'compact', handler defaults True, returns early without thought_chains. Fix: Don't mutate tool_args in should_compact() — read with .get(); OR pass the resolved use_compact flag down to handlers; OR have server.py inject the resolved flag so handlers stop reading the popped key.

#46 · resource-leak · src/neural_memory/server/rate_limit.py:42

Rate limiter keyed on spoofable X-Forwarded-For → trivially bypassed + unbounded memory growth _client_ip() trusts the leftmost X-Forwarded-For value as the key. Without a trusted reverse proxy normalizing this header, a caller can (a) defeat the limit by rotating XFF per request, and (b) cause unbounded growth: self._counters[ip] = deque() entries are created per distinct IP and NEVER removed (the eviction loop only popleft()s timestamps, never deletes the empty deque). A stream of unique fabricated XFF values permanently grows the dict — a memory-exhaustion DoS that also nullifies the limiter. Repro: Send N requests each with a unique X-Forwarded-For → each bypasses the window + adds a permanent dict entry; memory grows linearly with N. Fix: Honor XFF only when the direct peer is a configured trusted proxy; otherwise key on request.client.host. Add a reaper: del self._counters[ip] when a deque empties after eviction, and/or LRU-cap the dict.

#47 · logic · src/neural_memory/server/routes/brain.py:276

Merge endpoint clears brain before importing merged snapshot — crash window destroys all data merge_brain() does await storage.clear(brain_id) (276) then await storage.import_brain(merged_snapshot, brain_id) (278), no transaction. The try/except restores from local_snapshot on an import EXCEPTION, but a process crash/OOM/SIGKILL/DB-lock failure between clear and import leaves the brain permanently empty — the only pre-merge copy is the in-memory local_snapshot. Repro: POST /brain/{id}/merge; kill -9 immediately after clear() returns but before import_brain() commits → brain empty, local_snapshot gone with the process. Fix: Wrap clear+import in a single DB transaction, OR import under a temp brain_id and atomically swap, OR persist local_snapshot to disk before clearing.

#48 · dialect-parity · src/neural_memory/storage/postgres/postgres_fibers.py:22

Fiber.last_ghost_shown_at is never written/read on the native Postgres backend despite the column existing The Postgres migration adds fibers.last_ghost_shown_at TIMESTAMPTZ and the model has the field, but add_fiber/update_fiber never include it and row_to_fiber never reads it. The ghost-recall throttle never persists on Postgres — the column stays NULL, so ghosts can be re-shown repeatedly. The unified dialect path handles it correctly. Repro: Native Postgres: mark a fiber ghost-shown then re-query → last_ghost_shown_at always None; suppression never triggers. Fix: Include last_ghost_shown_at in add_fiber/update_fiber/ghost-update methods and set it in row_to_fiber via _to_naive_utc(record['last_ghost_shown_at']).

#49 · dialect-parity · src/neural_memory/storage/postgres/postgres_schema.py:36

neurons lifecycle columns (last_accessed_at, lifecycle_state, frozen) exist on SQLite but absent from native Postgres SQLite neurons has last_accessed_at TEXT, lifecycle_state TEXT DEFAULT 'active', frozen INTEGER DEFAULT 0 + indexes used by the Memory Lifecycle Engine. The native Postgres neurons DDL and migration list omit all three columns and indexes. Any lifecycle query referencing them raises UndefinedColumn (or silently degrades if guarded). With the missing ephemeral column (#25), the native Postgres neurons table is a strict subset. Repro: Native Postgres: run a lifecycle/access-tier sweep filtering/updating lifecycle_state/last_accessed_atUndefinedColumnError, or silently inert. Fix: Add last_accessed_at TIMESTAMPTZ, lifecycle_state TEXT DEFAULT 'active', frozen BOOLEAN/INTEGER DEFAULT 0 (+ two indexes) to the native Postgres schema + migrations; wire add/update paths.

#50 · logic · src/neural_memory/engine/retrieval_fusion.py:60

Per-channel min-max normalization gives a lone fiber a perfect 1.0, inflating single-result channels _normalize() returns dict.fromkeys(scores, 1.0) whenever spread < 1e-12 — including the common case of a channel with exactly ONE fiber. fuse_scores normalizes each channel (graph/semantic/lexical) independently, so a channel matching only a single fiber assigns it the max 1.0 regardless of raw score. That fiber gets the full channel weight and can outrank fibers that scored strongly in a denser channel. Repro: semantic_scores={'fiberA':0.01} (one weak hit), graph has 10 strong fibers → fiberA's semantic normalizes to 1.0; with semantic weight 0.3, fiberA gets 0.3 from one weak hit and can leapfrog graph-dominant fibers. Fix: Distinguish "single element" from "all-equal multi": for a singleton (or hi==lo with hi≤0) return a neutral mid (e.g. 0.5) or 0.0; require ≥2 distinct values before a channel contributes a 1.0.

#51 · logic · src/neural_memory/engine/interference.py:139

detect_interference fan-effect count excludes near-duplicates and is capped at 2*max_candidates same_tag_count (the fan-effect driver) increments only for candidates passing the near-duplicate skip (dist ≥ _NEAR_DUPLICATE_THRESHOLD, line 136). Candidates are gathered only up to target = max_candidates*2 (default 40). So the count compared against fan_effect_threshold (default 15) (a) omits near-duplicate same-tag memories — exactly the densest clusters — and (b) is hard-capped at ~40. A 200-member near-identical cluster may report same_tag_count below threshold and never flag, while batch_interference_scan (which counts correctly) would. Repro: Encode a neuron sharing a tag with 100 near-dup neurons → all skipped at 136, same_tag_count ~0, no FAN_EFFECT, yet batch_interference_scan flags the tag overcrowded. Fix: Count same-tag memories independently of the near-dup skip (move the counter above the dist<NEAR_DUPLICATE continue), and derive the fan count from a dedicated tag-count query like batch_interference_scan.


LOW

#52 · logic · src/neural_memory/storage/sql/dialect.py:106-115

base Dialect.execute_returning_count() always returns 0 (queries a literal SELECT 0) The default runs the caller's write, then SELECT 0 as cnt and returns row['cnt'] — hardcoded 0. SQLite/Postgres override correctly, but any dialect not overriding (the base is concrete, not @abstractmethod) silently reports 0 affected rows for INSERT...SELECT. Callers using the count to decide success mis-branch. Repro: A third/base dialect's INSERT...SELECT affecting N rows returns 0 → caller treats a successful bulk insert as a no-op. Fix: Make execute_returning_count abstract (like execute_count), or raise NotImplementedError in the base rather than returning a fake 0.

#53 · api-consistency · src/neural_memory/storage/sql/row_mappers.py:66-86

row_to_neuron dual-signature detection silently treats (row, dialect) as row-only The shim: if row_or_dialect is None → row=arg1; elif arg1 has .name → row=arg2; else row=arg1. Calling row_to_neuron(row, dialect) (the documented order) sets row=arg1 and silently DISCARDS the dialect — so the promised explicit-dialect datetime normalization never happens; _normalize_dt auto-detection is always used. A misplaced docstring sits after executable code (77). The "optional dialect parameter" API is non-functional. Repro: row_to_neuron(row, postgres_dialect) → dialect dropped, auto-detection runs; no crash but the contract is a lie. Fix: Either remove the dialect parameter (auto-detect suffices) and simplify to single-arg, or actually use dialect.normalize_dt(). Move the docstring above code. Apply across all row_to_* shims.

#54 · resource-leak · src/neural_memory/storage/sql/mixins/cognitive.py:262

refresh_hot_index is non-atomic — DELETE auto-commits before INSERTs, partial failure wipes index Claims to "Replace the hot index" but issues a standalone DELETE (262) then per-item INSERTs (269) with no d.transaction(). Each statement auto-commits. If any INSERT raises (malformed item missing slot/category/neuron_id/score via [] → KeyError, or constraint violation), the index is left wiped/partial. Same gap in add_fiber/update_fiber (fibers + junction across separate commits). Repro: refresh_hot_index([{...valid...}, {'category':'x'}]) → second item missing 'slot' → KeyError after DELETE committed and first row inserted → hot_index has 1 of N rows. Fix: Wrap DELETE + INSERT loop in async with d.transaction():; validate items / use .get() defaults.

#55 · resource-leak · src/neural_memory/storage/sql/mixins/fibers.py:385

update_fiber junction refresh is non-atomic: DELETE commits before re-insert update_fiber UPDATEs the fibers row, then DELETEs fiber_neurons (385), then re-inserts (395) — each auto-commits. If the re-insert (execute_many) fails, the junction is empty while the fibers row claims neuron_ids → contains_neuron/find_fibers_batch silently miss the fiber. add_fiber has the same split-write shape. Repro: Force execute_many to fail after the junction DELETE → fiber_neurons rows gone; find_fibers(contains_neuron=nid) no longer returns the fiber. Fix: Wrap update_fiber's UPDATE + junction DELETE + re-insert (and add_fiber's INSERT + junction insert) in async with d.transaction():.

#56 · logic · src/neural_memory/storage/sql/mixins/fibers.py:489

list_pinned_fibers fallback aliases summary AS type but reads non-existent memory_type key When the typed_memories JOIN fails, the fallback selects summary AS type, 5 AS priority, but the mapper (504–505) reads d_row.get('memory_type') and d_row.get('priority'). The fallback has columns named 'type'/'priority', so 'memory_type' is absent → type always 'unknown', and summary AS type is dead. Repro: Drop/rename typed_memories so the primary query raises → list_pinned_fibers returns every pinned fiber with type='unknown'. Fix: Alias the fallback to the names the mapper reads: 'unknown' AS memory_type, 5 AS priority; drop the unused summary AS type.

#57 · sql-correctness · src/neural_memory/storage/sql/mixins/change_log.py:73

record_change derives inserted id via MAX(id) filtered by entity, returning a stale id when prior change rows exist After insert, it reads the new sequence id with SELECT MAX(id) FROM change_log WHERE brain_id=? AND entity_type=? AND entity_id=?. change_log accumulates multiple rows per entity over time; under any concurrency this can return a different row's id than the one just inserted → wrong sequence number. Also a redundant round-trip. SQLite/asyncpg expose the true last-insert id. Repro: Two concurrent record_change for the same entity_id → each may read back the other's/the max id, corrupting sequence tracking used by mark_synced/get_changes_since. Fix: Use INSERT ... RETURNING id (Postgres + modern SQLite) via a dialect helper, or cursor.lastrowid.

#58 · dialect-parity · src/neural_memory/storage/sql/mixins/merkle.py:238

get_bucket_entity_ids prefix match diverges from MerkleTreeBuilder bucketing for sub-2-char entity IDs MerkleTreeBuilder.build_tree buckets by entity_id[:2].lower() if len>=2 else entity_id.lower().ljust(2,'0'), so a 1-char id 'a' lands in bucket 'a0'. get_bucket_entity_ids queries LOWER(SUBSTR(id,1,2)) = hex_prefix; for a 1-char id SUBSTR(id,1,2) returns 'a' (length 1), never equal to 'a0' → omitted from the bucket list. Currently unreachable (UUIDs ≥2 chars) but a latent builder/reader divergence. Repro: Hypothetical 1-char id 'a' → builder files under 'a0' but get_bucket_entity_ids('a0') excludes it. Fix: Pad in SQL to match the builder: SUBSTR(id||'00',1,2) lowercased, or normalize short ids consistently in both places.

#59 · error-handling · src/neural_memory/storage/sql/mixins/projects.py:45

add_project converts any insert failure into 'already exists', masking schema/DB errors add_project wraps INSERT in except Exception: raise ValueError(f"Project {project.id} already exists"). Any failure (connection error, NOT NULL violation, serialization, disk full) is reported as a spurious 'already exists', hiding the real fault. import_brain's _import_projects logs-and-skips on ValueError → silently drops valid projects on a transient error. Repro: Trigger any non-uniqueness insert error while adding a project → caller sees a false 'already exists'. Fix: Catch only the dialect's integrity/unique-violation type and re-raise others (preserve original as __cause__), or check existence first.

#60 · resource-leak · src/neural_memory/storage/postgres/postgres_brains.py:182-351

import_brain performs dozens of separate writes with no transaction — partial/orphaned state on failure Each neuron/state/synapse/fiber/fiber_neuron INSERT is an independent self._query call (own pooled connection), no transaction. If any insert fails midway (bad enum, vector encode error #24), the brain row and a partial subset are already committed → inconsistent half-imported brain. Also the path used by migrate_sqlite_to_postgres, so a mid-migration error leaves a partially-migrated brain that import_brain can't cleanly retry. Repro: import_brain on a snapshot where one neuron has a type not in NeuronType (or relies on #24) → brains row + neurons before the failing one persist, the rest don't. Fix: Wrap the whole import in one acquired connection + async with conn.transaction(): so it's all-or-nothing.

#61 · type-mismatch · src/neural_memory/storage/postgres/postgres_typed.py:308-342

promote_memory_type binds a str to TIMESTAMPTZ expires_at (latent dialect divergence) Signature new_expires_at: str | None binds straight into TIMESTAMPTZ ($2). asyncpg requires a datetime/None; a non-None ISO string raises DataError. SQLite/TEXT accept the string → diverges by dialect. Currently latent: the only caller (consolidation.py:1286) passes None. Repro: promote_memory_type(fiber_id, ..., new_expires_at='2027-01-01T00:00:00') on Postgres → DataError; succeeds on SQLite. Fix: Parse to datetime before binding (datetime.fromisoformat(...) if ... else None), matching pro/storage_adapter_typed.py:200, or change the contract type to datetime|None.

#62 · logic · src/neural_memory/storage/sqlite_schema.py:580

v30→v31 FTS-rebuild migration drops wrong fiber-trigger names (fibers_fts_* vs actual fibers_*) The migration intends to rebuild FTS with the new remove_diacritics 2 tokenizer. It drops fibers_fts_au/ad/ai (580–582), but the triggers created by FIBER_FTS_SETUP_STATEMENTS are named fibers_ai/ad/au. So the real triggers are never dropped. Non-fatal today because SQLite permits dropping a table referenced by triggers and ensure_fiber_fts_tables recreates with IF NOT EXISTS (unchanged bodies). But the DROPs are dead and correctness silently depends on trigger bodies never changing. Repro: Migrate <31 → ≥31; the DROP TRIGGER IF EXISTS fibers_fts_* match nothing; real fibers_au/ad/ai survive (inspect sqlite_master). Fix: Correct the names to fibers_au/fibers_ad/fibers_ai.

#63 · logic · src/neural_memory/storage/neuron_cache.py:34

NeuronLookupCache key omits the ephemeral filter dimension, can return wrong-filtered results Key is (content, type_value) only. In find_neurons(), a result produced WITH an ephemeral filter (AND ephemeral = ?, 285–287) is cached under a key ignoring ephemeral, and a cache hit is returned regardless of the caller's ephemeral argument. So find_neurons(content_exact='x', ephemeral=True) populates the cache, and a later ephemeral=False call gets the ephemeral-only set back. No current caller mixes content_exact with ephemeral, so latent today. Repro: find_neurons(content_exact='foo', ephemeral=True) then find_neurons(content_exact='foo', ephemeral=False) → second returns cached ephemeral=True results. Fix: Include ephemeral in the key (content, type_value, ephemeral), or only consult/populate the cache when ephemeral is None (mirror created_before/time_range guards).

#64 · async-concurrency · src/neural_memory/storage/read_pool.py:58

ReadPool round-robin can hand the same connection to concurrent readers, serializing/aliasing cursors acquire() returns connections round-robin with no in-use tracking. Under asyncio.gather() of >pool_size reads (the stated purpose), multiple coroutines receive the SAME aiosqlite.Connection. aiosqlite serializes per connection through one worker thread, so true parallelism is lost beyond pool_size, and interleaved cursor lifecycles on one connection are fragile. Repro: gather of 6 find_neurons with pool_size=3 → indices 0,3 / 1,4 / 2,5 share connections, running serially. Fix: Track in-use connections with an asyncio.Queue or free-list + Lock; acquire() should await a free connection and callers release it (acquire/release or async CM). Dovetails with the #9/#10 contract fix.

#65 · error-handling · src/neural_memory/engine/encoder.py:369

Embedding/post-encode init swallows all exceptions via except (ImportError, Exception) In MemoryEncoder.__init__, the optional EmbeddingStep wiring is wrapped in except (ImportError, Exception): pass. Exception subsumes ImportError, and the bare catch discards real config/programming errors (TypeError in _create_provider, bad config, attribute error from self._pipeline._steps). A misconfiguration disables embeddings permanently with zero signal. Also reaches into private Pipeline._steps. Repro: Misconfigure embedding (wrong model name → non-ImportError) → construction succeeds, embedding_enabled True, but no EmbeddingStep appended, nothing logged → semantic search degrades to keyword-only. Fix: except ImportError: logger.debug(...) plus a separate except Exception: logger.warning(..., exc_info=True). Prefer a public Pipeline.append().

#66 · logic · src/neural_memory/engine/pipeline_steps.py:376

Action/Intent extraction truncates BEFORE dedup, dropping unique results ExtractActionNeuronsStep slices matches[: self.MAX_ACTIONS] (376) then dedups within that slice. If the first MAX_ACTIONS (5) matches contain duplicates, dups consume slots and fewer unique actions survive though more exist later. Same in ExtractIntentNeuronsStep (436, MAX_INTENTS=3). Repro: 'fixed the bug ... fixed the bug ... fixed the bug ... deployed X ... shipped Y ... merged Z' → three dup 'fixed the bug' eat the 5-slot window; 'shipped Y'/'merged Z' dropped. Fix: Dedup first, then cap: build the seen-set/valid list over all matches, then valid_actions = valid_actions[: self.MAX_ACTIONS]. Same for intents.

#67 · logic · src/neural_memory/extraction/temporal.py:404

Vietnamese hour disambiguation: 'chiều'/'tối' with hour==12 not normalized; resolver branch is dead _resolve_vi_hour only adds 12 when hour < 12 for 'chiều'/'tối'. For '12 giờ tối' (midnight, hour=12) the 'tối' branch doesn't fire and nothing maps it to 0/24, so it resolves to 12:00 noon — the opposite of intent. The if/else at 411–416 computes nothing in either branch (dead code). Granularity is forced HOUR but the range is ±30min (MINUTE granularity) — inconsistent. Repro: extract('họp lúc 12 giờ tối') resolves to ~12:00 (noon) instead of ~00:00. Fix: Handle hour==12 explicitly per period ('tối'/'đêm' midnight 12→0). Remove the no-op if/else or implement the today/yesterday selection it gestures at.

#68 · async-concurrency · src/neural_memory/safety/encryption.py:73

Encryption key first-write has an uncaught TOCTOU race (FileExistsError) _get_or_create_cipher checks key_path.exists() (73) and, if absent, on POSIX opens with os.O_CREAT | os.O_EXCL (82). If a second process creates the same brain key between check and open, O_EXCL raises FileExistsError, uncaught, propagating out of encrypt(). O_EXCL is correct for safety but the lost race is unhandled. Repro: Two concurrent encrypt(content, brain_id) for a brand-new brain → one wins O_EXCL, the other raises FileExistsError out of MemoryEncryptor.encrypt. Fix: Wrap the os.open(...O_EXCL...) in try/except FileExistsError; on collision read the now-existing key and build the cipher from it.

#69 · logic · src/neural_memory/core/brain_mode.py:223

BrainModeConfig.to_dict masks api_key to ''; from_dict reads it back literally (round-trip corruption) to_dict() replaces api_key with '***' (223, 233) for safe display. from_dict() (251–271) reads s.get('api_key')/h.get('api_key') straight. If any code persists via to_dict() and reloads via from_dict() (the symmetric pairing their names imply), api_key becomes literal '***', breaking auth to the server. Currently the round-trip isn't wired in this package — latent. Repro: cfg2 = BrainModeConfig.from_dict(cfg.to_dict())cfg2.shared.api_key == '***' (was 'secret'). Fix:* Keep masking only in a dedicated to_safe_dict(); have to_dict() emit the real key for serialization; or have from_dict() treat '***' as unchanged/None. Document non-round-trippability if masking is intentional.

#70 · async-concurrency · src/neural_memory/utils/consolidation_lock.py:136

release_consolidation_lock unconditionally unlinks lock on JSON parse error — can delete another agent's live lock release_consolidation_lock reads the lock and unlinks only when data['pid']==os.getpid(). But on JSONDecodeError/OSError it falls to the except branch (136–140) and unconditionally unlinks missing_ok=True. acquire writes the payload after O_EXCL in a separate non-atomic os.write, so a concurrent reader can hit a partially-written lock. The releasing process can delete a lock belonging to a different live run → concurrent consolidation (the exact corruption this module prevents). (confidence: medium) Repro: Agent A holds the lock mid-write; Agent B releases its own work, its read of A's lock raises JSONDecodeError, the except branch unlinks A's lock; Agent C acquires via O_EXCL and consolidates concurrently with A. Fix: Don't delete the lock on parse/read failure; unlink only when the pid is confirmed our own. Treat an unreadable lock as "not ours" and leave reclamation to the stale-timeout path. Optionally write the payload atomically (temp + O_EXCL rename).

#71 · error-handling · src/neural_memory/server/routes/sync.py:317

WebSocket session torn down by a single malformed message; arbitrary brain_id event history grows unbounded The receive loop does json.loads(data) (317) and downstream SyncEvent.from_dict (362) and datetime.fromisoformat(since) (377) with no per-message try/except. Any JSONDecodeError/KeyError/ValueError from one bad message propagates to the outer except Exception (394), closing the whole connection — self-DoS from one frame. Separately, the 'event' action accepts an arbitrary client-supplied brain_id; broadcast() creates a new _event_history[brain_id] per distinct id (216–217) never evicted → memory growth. Repro: Send { (invalid JSON) or {"action":"event","event":{}} (missing keys → KeyError) → connection closed. For the leak: loop {"action":"event","event":{"type":"error","brain_id":"<unique-N>"}} to accumulate unbounded history keys. Fix: Wrap the loop body in try/except (JSONDecodeError, KeyError, ValueError) that sends an error frame and continue. Validate brain_id against the pattern/length for 'event' and 'get_history' as already done for 'subscribe'; bound the number of distinct history buckets.

#72 · type-mismatch · src/neural_memory/mcp/remember_handler.py:443

MCP tool args not validated against schemas; type confusion silently corrupts tags The dispatch path never validates raw_args against the published JSON schema — args go straight from json.loads to handlers. _remember_save does not guard tags: raw_tags = args.get("tags", []) then len(raw_tags) > 50 and for t in raw_tags (443–448). If a client sends tags as a string, len() measures characters and the loop iterates characters → tags="python" stored as {'p','y','t','h','o','n'}. Other spots take the same risk (e.g. store_handler.py:72 min(args.get('limit',20),50) raises TypeError on a string, swallowed into a generic error). Repro: nmem_remember {content:'x', tags:'python'} → stored with six single-char tags, no error. Fix: Validate tags is a list before iterating (mirror _parse_tags() in tool_handler_utils.py). Ideally validate tools/call arguments against the declared input schema once in handle_message before dispatch.

#73 · dialect-parity · src/neural_memory/storage/postgres/postgres_schema.py:153

typed_memories.project_id FK to projects present on SQLite but omitted from native Postgres SQLite declares FOREIGN KEY (brain_id, project_id) REFERENCES projects(brain_id, id) ON DELETE SET NULL, so deleting a project nulls the link. The native Postgres typed_memories table only declares FKs to fibers and brains. On Postgres, deleting a project leaves dangling project_id values instead of SET NULL. Repro: Native Postgres: assign a typed memory to a project, delete the project → project_id remains stale (no SET NULL), unlike SQLite. Fix: Add the composite FK (brain_id, project_id) → projects(brain_id, id) ON DELETE SET NULL to the native Postgres typed_memories DDL.

#74 · type-mismatch · src/neural_memory/storage/sqlite_sessions.py:72

session_summaries top_entities tuples round-trip to lists via JSON, silently changing element type save_session_summary accepts top_entities: list[tuple[str,int]] and stores via json.dumps (72). JSON has no tuple type; each pair serializes as a 2-element array. get_recent_session_summaries (119) reads back list[list] not list[tuple]. Consumers relying on tuple semantics (hashing, set membership, == against a tuple) get a different type. Repro: save_session_summary(top_entities=[('alice',3)]); read back == [['alice',3]], and [('alice',3)] != [['alice',3]]. Fix: On read coerce top_entities=[tuple(e) for e in json.loads(row[3])], or change the contract type to list[list[Any]] and update callers.

#75 · logic · src/neural_memory/engine/prediction_error.py:179

compute_surprise_bonus reports maximum novelty (2.0) when candidate neurons lack content hashes min_hamming initialized to 64 (worst case), updated only inside the loop when neuron.content_hash and content_hash is truthy (192). If all tag-matched candidates have content_hash==0/None (legacy neurons), min_hamming stays 64 → returns surprise=2.0 ('very different') even when content is a near-duplicate. Inflates priority for redundant content. Repro: Insert neurons with unset content_hash, then encode near-identical content with overlapping tags → candidates found but, lacking hashes, min_hamming never lowered → returns 2.0 instead of ~0.0. Fix: When a candidate has no usable hash, compute simhash(neuron.content) on the fly (as detect_interference does) before hamming_distance, or track whether any comparison occurred and fall back to the no-candidate path.

#76 · logic · src/neural_memory/engine/prediction_error.py:115

_detects_reversal opposite-pair matching uses substring in, causing false contradiction signals Opposite-adjective detection uses bare substring membership: if (pos in a_lower and neg in b_lower) or .... So 'hard' matches inside 'hardly'/'harder'/'hardware', 'safe' inside 'unsafe', 'works' inside 'networks'. False reversals set surprise to 2.5 and inflate priority. The English fallback (131) has the same flaw. Repro: content_a='the hardware is fine', content_b='hardly any issues' → 'hard' present in both via 'hardware'/'hardly' with pair ('easy','hard') → spurious reversal → surprise 2.5. Fix: Match on word boundaries (tokenize and compare whole tokens, or \b regex). For multi-word VI pairs keep phrase matching but anchor on boundaries.

#77 · logic · src/neural_memory/engine/retrieval.py:2114

Supersession chain 'is current superseded' check is dead code (loop body never alters state) In the supersession chain loop, when no forward hop is found the 'is current pointed TO by a SUPERSEDES?' block (2114–2121) iterates next_syns but every branch is if ns.type in SUPERSESSION_SOURCE_IS_NEWER: continue with no else — the loop does nothing, then breaks. No variable is read or written. The intended check (demoting a 'latest' that is actually superseded by an unvisited node) is absent. Repro: A RESOLVED_BY B, and C SUPERSEDES B (C newer). Tracing from A reaches B as 'ultimate_latest' and boosts B, missing that C supersedes B. Fix: Remove the dead block, or implement it: query incoming SUPERSEDES/EVOLVES_FROM edges to current; if a newer unvisited node points to current, continue the walk to it.


INFO

#78 · security · src/neural_memory/safety/sensitive.py:147

Credit-card detector lacks Luhn validation, causing over-broad redaction of arbitrary 13–16 digit numbers The CREDIT_CARD pattern matches any 13/15/16-digit string with a valid card prefix but performs no Luhn checksum. Long numeric IDs (order IDs, tracking numbers) matching a Visa/MC prefix are flagged as high-severity cards and redacted/blocked in auto_redact_content(min_severity=3)/brain scan. Over-redacts (precision/usability) rather than leaks — acceptable as fail-safe but worth a Luhn gate. Repro: check_sensitive_content('order 4111111111111234') flags a CREDIT_CARD even though the number fails Luhn. Fix: Run a Luhn check on the digit string after the regex match; drop matches that fail the checksum.

#79 · api-consistency · src/neural_memory/mcp/lifecycle_handler.py:159

nmem_forget hard-delete has no confirmation gate, unlike nmem_store delete nmem_store action='delete' requires confirm=true before clearing a brain and returns a pending_confirmation preview otherwise. nmem_forget with hard=true permanently deletes a fiber+typed_memory+neurons (190–210) or a bare neuron (179–185) with no confirmation. Single-memory blast radius, but the inconsistent safety contract makes accidental permanent deletion via one tool call easy. Repro: nmem_forget {memory_id:'<id>', hard:true} immediately and irreversibly deletes with no confirm. Fix: Add an optional confirm preview to hard deletes (or document the asymmetry intentionally); align the contract across destructive tools.

#80 · api-consistency · src/neural_memory/server/routes/../app.py:783

graph endpoint reports misleading total_neurons (only offset+limit, not real total) get_graph_data fetches all_neurons = find_neurons(limit=offset + capped_limit) then sets total_neurons = len(all_neurons) (783–784). Because the fetch is capped at offset+limit, total_neurons never exceeds offset+capped_limit — it just equals the number fetched. A paginating client under-counts pages and stops early. Also inefficient (materializes offset+limit rows to slice the last limit). Repro: With 5000 neurons, GET /api/graph?offset=0&limit=500 returns total_neurons=500 (not 5000); the UI believes there's one page. Fix: Return a true count via a dedicated count query (storage.count_neurons/get_stats), and use a keyset/proper offset query.

#81 · api-consistency · src/neural_memory/engine/enrichment.py:39

find_transitive_closures accepts max_depth but only ever infers depth-2 (A→B→C) The signature/docstring advertise max_depth and enrich() forwards it, but the implementation hardcodes a two-level nested loop (A targets → B targets) and never consults max_depth. A caller passing max_depth=3 expecting A→B→C→D silently gets only A→C. The parameter is dead and misleading. Repro: find_transitive_closures(storage, max_depth=3) is identical to max_depth=2 — no A→D synapses for a 4-node chain. Fix: Implement depth>2 traversal (iterative closure up to max_depth) or drop the parameter and update the docstring/enrich().

#82 · api-consistency · src/neural_memory/engine/confidence.py:122

ConfidenceScore components mislabels numeric fidelity score under key 'fidelity_layer' The components dict (for transparency/debugging) stores the numeric fidelity score under 'fidelity_layer': "fidelity_layer": fidelity. The key name implies the layer name string (e.g. 'verbatim'), but the value is the mapped float. Consumers inspecting components['fidelity_layer'] expecting the label get a number, and the actual layer name is dropped. Repro: compute_confidence(fidelity_layer='gist').components['fidelity_layer'] == 0.4 (a float), not 'gist'. Fix: Store the layer string under 'fidelity_layer' and the numeric value under a distinct key like 'fidelity_score', or drop the redundant entry.


PR-1 — Sync engine data-loss & correctness (BLOCKER) — closes #1, #12, #13, #42, #43, #44

The single CRITICAL plus the most damaging HIGH/MEDIUM sync findings. Ship first; these silently destroy or diverge user data. - #1: Make Merkle reconcile non-destructive — push local changes (or gate deletes on synced=1) before computing hub-absence deletes. - #12: mark_synced against the local id space (max(c.sequence)), keep update_device_sync for the hub watermark. - #13: Apply the computed merged change list (neural-aware) instead of raw request.changes. - #42: Parse changed_at to normalized naive-UTC before comparing; canonicalize ISO at ingestion. - #43: Maintain fibers.updated_at (or fingerprint mutable fields) in the Merkle leaf. - #44: Per-bucket SQL prefix fetch instead of a 10000-row global slab. Recommend a dedicated multi-device sync integration test harness as part of this PR.

PR-2 — Postgres schema/runtime type alignment — closes #2, #3, #4, #5, #14, #15, #25, #48, #49, #73 (and unblocks #24, #61)

Stop the DDL text-mangling root cause and bring native Postgres / dialect-Postgres to parity with SQLite. - #2 (root): get_schema_ddl() returns the real postgres_schema.py DDL → automatically closes #3, #4, #5. - #14, #48, #25, #49, #73: add the missing columns/FKs/migrations + wire add/update/row_to_* (essence, last_ghost_shown_at, ephemeral, lifecycle columns, project_id FK). - #15: pin the pool(s) to TimeZone=UTC and/or bind tz-aware UTC datetimes; same for the dialect pool. - #24: register the pgvector codec (or text-form + ::vector cast) on each pooled connection. - #61: parse new_expires_at to datetime before binding.

PR-3 — Dialect parity (query-level) — closes #6, #18, #19, #20, #23, #7, #26, #29, #30

Backend-divergent query behaviors that crash or silently mis-filter. - #6: dialect-aware JSON tag param shape (+ Postgres tag-filter integration test). - #18: Postgres single-array param for direction='both'. - #19: parse expires_at to datetime in promote_memory_type (dialect mixin). - #20: escape LIKE wildcards in the Postgres ILIKE branch. - #23: portable day-prefix / date_trunc_day() helper. - #7: float(row["priority"]) (project read regression). - #26: generic BrainConfig reconstruction + fix embedding_enabled default. - #29: forward ephemeral/created_before in SharedStorage.find_neurons. - #30: make HybridStorage a real NeuralStorage (add brain_id/batch/extended surface), drop the type: ignore.

PR-4 — Connection pool & transaction lifecycle — closes #9, #10, #64, #17, #31, #47, #54, #55, #60

Fix the ReadPool acquire/release contract, the dialect transaction footgun, and non-atomic multi-statement writes. - #9/#10/#64: ReadPool @asynccontextmanager with proper acquire/release + in-use tracking; never use the pooled Connection as async with target. - #17: contextvars.ContextVar for the dialect transaction connection. - #31/#47: atomic clear+import (temp brain id + swap, or single transaction) for both merge paths. - #54/#55/#60: wrap DELETE+INSERT / junction refresh / import_brain in transactions.

PR-5 — Retrieval/recall correctness — closes #11, #16, #32, #33, #50, #51, #77, #34

Recall-quality and ranking bugs (several backend-divergent). - #11: de-indent entity/keyword anchor appends out of the else (InfinityDB path). - #16: track per-neuron seed-set provenance for PPR intersection. - #32: use f.anchor_neuron_id for the contribution signal. - #33/#77: fix supersession demotion bookkeeping and the dead "is current superseded" check. - #50: neutral score for singleton channels in fusion normalization. - #51: count same-tag memories independent of the near-dup skip. - #34: redefine causal total_weight (single path or normalized aggregate) + docstring.

PR-6 — Encode pipeline & extraction — closes #37, #38, #66, #67, #75, #76, #65

Feature-dead and extraction-quality fixes. - #37: order entity-ref promotion vs retroactive linking so refs aren't promoted=1 before the link step reads them. - #38: thread entity neuron type into _find_similar_entity dedup. - #66: dedup before cap in action/intent extraction. - #67: VN hour==12 disambiguation + remove dead branch. - #75/#76: on-the-fly simhash fallback + word-boundary opposite-pair matching. - #65: narrow embedding-init exception handling + use public Pipeline.append().

PR-7 — Error-handling hardening — closes #21, #22, #59, #28, #27

Replace catch-all except Exception with specific integrity/FK exceptions and fix the two OperationalError SELECTs. - #21: narrow neuron-state catch. - #22: real upsert for save_maturation (unblocks EPISODIC→SEMANTIC maturation). - #59: narrow add_project catch. - #27/#28: fix non-existent type/priority/stage columns in list_pinned_fibers / get_fiber_stage_counts.

PR-8 — MCP/server surface & DoS hardening — closes #45, #46, #71, #72, #79, #80

  • 45: stop popping compact in should_compact() (or pass the resolved flag down).

  • 46: trust XFF only from configured proxies + reap empty deques / LRU-cap.

  • 71: per-message try/except + brain_id validation + bounded history buckets.

  • 72: validate tags is a list (and ideally schema-validate tool args before dispatch).

  • 79: optional confirm preview for nmem_forget hard=true.

  • 80: true neuron count for the graph endpoint.

PR-9 — Core model & crypto safety — closes #39, #40, #41, #69, #68, #70

  • 39/#69: preserve fields in frozen-dataclass updates (use dataclasses.replace); separate to_safe_dict() from serialization.

  • 40/#41: two-pass surface node-id assignment; map cluster UUIDs to surface ids.

  • 68: handle FileExistsError (TOCTOU) in encryption key first-write.

  • 70: don't unlink the consolidation lock on parse/read failure.

PR-10 — Latent/contract cleanups — closes #52, #53, #57, #58, #62, #63, #74, #81, #82, #78

Low-risk hardening and correctness-contract fixes that don't fit the above clusters: abstract execute_returning_count (#52), row-mapper dual-signature (#53), RETURNING id for change-log (#57), Merkle short-id padding (#58), v30→v31 trigger names (#62), cache key ephemeral dimension (#63), session top_entities tuple coercion (#74), find_transitive_closures depth (#81), confidence components key (#82), credit-card Luhn gate (#78).


End of report.