Skip to content

Fix three memU write paths that damage store integrity - #254

Closed
oranjeai wants to merge 7 commits into
ClickHouse:mainfrom
oranjeai:oranjeai/memu-write-path-integrity
Closed

Fix three memU write paths that damage store integrity#254
oranjeai wants to merge 7 commits into
ClickHouse:mainfrom
oranjeai:oranjeai/memu-write-path-integrity

Conversation

@oranjeai

@oranjeai oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Symptom

Measured on a live 139,187-item store. memory_update, memory_delete and the web-UI
routes all reach these.

  • 154 of 154 items ever updated without a categories argument now hold zero category
    links - about 47% of all 326 orphaned items.
  • 149 of 149 updated items carry their old text's content_hash (400/400 never-updated
    items are fresh), so re-memorizing an item's pre-correction wording reinforces the corrected
    row rather than creating a new one.
  • 6,455 dangling memu_category_items rows, all 5,611 distinct item_ids present in the
    item_deleted audit log; they inflate memory_expand_category's total by 3.5-4.6% per
    category (relations counted, items listed through a JOIN).

Root cause

  1. memU has one sentinel for two meanings: an omitted categories becomes None, and
    _map_category_names_to_ids(None, ctx) returns [], so _patch_update_memory_item's
    cats_to_remove is the item's entire current set, each unlink recording
    (old_content, None) - read downstream as "discarded", so the LLM drops the item from the
    category summary too.
  2. content_hash is a derived field with no invalidation: update_item rewrites
    summary/memory_type and never touches it, while create_item_reinforce dedups on it.
  3. No layer owns the dependent rows: delete_item deletes only the item row; there is no
    FK or cascade.

The change

Four patches in _patch_sqlite_bugs(), the established seam for this class of memu-py defect
(precedent: #119).

  • Fix 8 - when categories is None, neutralise membership for that one call: the
    delegated handler gets a per-call proxy whose link/unlink are no-ops, so its diff removes
    nothing, and (old, new) is rebuilt from the surviving links. Nothing process-wide is
    mutated. Explicit list still replaces; explicit [] still clears.
  • Fix 9 - recompute content_hash from the DB row when summary/memory_type
    changes. Only an existing hash is refreshed.
  • Fix 10 - delete relations and the item in one transaction, so a failure can never
    leave a surviving item stripped of its links. Installed before Fix 3 so its vector-index hook
    still wraps it.
  • Fix 7 correction - the dedup writeback wrote a cache-derived extra over the row's, and
    read paths omit extra=, so a reinforce through a cold cache deleted content_hash and any
    key another writer had added. It now seeds extra from the row.

Validation

52 new tests. Each defect has a control arm driving memU's own unpatched function against an
identically-built fixture, so a test passing without the fix is a failed test, not a passing
fix. A 38-arm mutation matrix kills 35, each naming a failing test, no-op controls green at both
ends; the 2 survivors are unreachable by construction, measured. Full suite 2985 passed,
pre-existing failure set unchanged by name.

Scope, unfixed siblings, behaviour changes

Write paths only - no data is repaired. The store still holds 6,455 dangling relations, 326
orphans, 149 stale hashes and 6,473 hashless rows; the 154 lost memberships are not
recoverable
(the audit log records categories_changed: false, not the ids).

The Fix 7 wipe explains that population: all 6,473 have rc > 1 and none is a tool item, the
only create path that legitimately writes no hash.

Both fixes read the row, not the cache: a cached item has extra == {}, so a cache-based refresh
refreshes nothing in a long-lived process.

Pinned, not closed. Fix 9's CAS binds summary/memory_type, so any hash it writes is the
row's own, but the refresh is best-effort: if skipped (a lock) the row keeps its old hash, as
base always does - logged and asserted. A writer landing between memU's write and this read can
likewise leave the cache entry on that writer's hash, unread (dedup reads the DB). Fix 7 keeps
memU's read-modify-write shape, so a key written inside it is still lost (base loses every key).
All pinned by tests; a CAS is tracked apart.

Named but deliberately unfixed. Test-pinned so the exemption cannot rot: read paths still
omit extra= (no longer destructive, so hydration is its own change); clear_items has the same
dangling shape but no nerve callers; PatchMixin is dead code. Unreachable and untested:
the in-memory/postgres siblings (provider hardcoded sqlite).

Behaviour changes. A reinforce preserves more of extra, including ref_id, which
list_items_by_ref_ids filters on. Hash-dedup now matches rows whose hash used to be wiped, so
some memorizations reinforce rather than duplicate (the intent). Recomputing the 149 stale
hashes collides with none of the 132,714 hashed rows; updating an item's text to exactly match
another's now yields two rows sharing a hash, handled as the dedup lookup handles two
identical memorize calls.

nerve's memory_update and memory_delete tools, and the equivalent web-UI
routes, all reach three defects in memu-py 1.4.0. All were reproduced against
the code on main and measured on a live 139,187-item store.

1. A content-only memory_update unlinks EVERY category of the item. memU has
   one sentinel for two meanings: _patch_update_memory_item maps a missing
   `categories` argument to [] (_map_category_names_to_ids returns [] for a
   falsy list), so cats_to_remove becomes the item's entire current set. 154 of
   the 154 items ever updated without a categories argument now hold zero
   category links, about 47 percent of all 326 orphaned items. Worse, each
   unlink records (old_content, None), which the summary-patch step renders as
   "This memory content is discarded", so the LLM rewrites the category summary
   to drop the item too. Fix 8: when `categories` is None, rewrite the payload
   with the names of the item's current links, so memU's own diff removes
   nothing and records (old, new) instead. An explicit list still replaces and
   an explicit [] still clears.

2. update_item never refreshes extra.content_hash, which create_item_reinforce
   dedups on, so an updated item keeps its old text's hash forever: 149 of 149
   updated rows are hash-stale against a 400/400 fresh never-updated baseline.
   The consequence is measurable, not theoretical - correct an item's wording,
   re-memorize the old wording, and the corrected row is reinforced under its
   new text. Fix 9: recompute the hash from the DB row when summary or
   memory_type changes. Reading the ROW rather than get_item() is load-bearing:
   read paths build MemoryItem without extra=, so a cached item has extra == {}
   and a cache-based version would refresh nothing in any long-lived process
   while still passing a create-path test. Only a hash that already exists is
   refreshed, so an item created without one is not newly enrolled into dedup.

3. delete_item deletes the item row and leaves its memu_category_items rows
   behind - there is no FK and no ON DELETE CASCADE, and no layer owns the
   dependent rows. 6,455 dangling relations, and every one of the 5,611
   distinct dangling item_ids is in the item_deleted audit log. They also
   inflate memory_expand_category's reported total, which counts relations
   while listing through a JOIN, by 3.5 to 4.4 percent on every category.
   Fix 10: delete relations and the item in ONE transaction, so a failure can
   never leave a surviving item stripped of its links. Installed before Fix 3
   so its vector-index hook still wraps it.

The same investigation found the cause of a fourth, related population: Fix 7's
semantic-dedup writeback built `extra` from the item CACHE and assigned it over
the row's whole extra. Since read paths omit extra=, a reinforce through a cold
cache replaced the row's extra with just its own two salience keys, deleting
content_hash and any key another writer had added. All 6,473 rows with no
content_hash carry reinforcement_count > 1, none carry rc == 1, and the store
holds zero items of type "tool" - the only create path that legitimately writes
no hash - so that whole population is wipe damage. The writeback now seeds
`extra` from the row inside its existing transaction and refreshes the cache
from what was written, which is correct for every writer rather than only the
ones we know about: a raw-SQL sweep in this file adds extra.mentioned_at without
touching the cache, and that key is now preserved too.

All four patches live in _patch_sqlite_bugs(), the established seam for this
class of memu-py defect, which already carries seven numbered fixes.

Scope. This fixes the write paths only; it repairs no existing damage, and the
154 lost category memberships are not recoverable (the audit log records only
categories_changed: false, never the ids). Named but deliberately not fixed:
the read paths still omit extra= (no longer destructive now that the writeback
reads the row, so hydration is a separate change); clear_items has the same
dangling-relation shape but zero nerve callers; the in-memory and postgres repo
siblings and the in-memory reinforce arm are unreachable because the provider
is hardcoded sqlite; memu.app.patch.PatchMixin's duplicate handlers are dead
code. Tests pin each of those facts so an exemption cannot rot silently.

Behaviour changes worth noting: a reinforce now preserves more of extra,
including ref_id, which list_items_by_ref_ids filters on; hash-dedup now
matches rows whose hash used to be wiped, so some memorizations reinforce
instead of duplicating (which is the configured intent); and Fix 8 raises
rather than unlinking if an item's category ids do not round-trip through
ctx.category_name_to_id, which cannot happen through a supported path because
nerve rebuilds that map from every DB category on init.

Tests: 31 new tests in tests/test_memu_bridge.py. Each defect has a control arm
driving memU's own unpatched function against an identically-built fixture, so
a test that passes without the fix is a failed test rather than a passing fix.
A 13-mutant matrix over the four patches kills a test for every mutant, with a
no-op control that stays green. Full suite 2964 passed, with the pre-existing
failure set unchanged by name.
nerve's memory_update and memory_delete tools, and the equivalent web-UI
routes, all reach three defects in memu-py 1.4.0. All were reproduced against
the code on main and measured on a live 139,187-item store.

1. A content-only memory_update unlinks EVERY category of the item. memU has
   one sentinel for two meanings: _patch_update_memory_item maps a missing
   `categories` argument to [] (_map_category_names_to_ids returns [] for a
   falsy list), so cats_to_remove becomes the item's entire current set. 154 of
   the 154 items ever updated without a categories argument now hold zero
   category links, about 47 percent of all 326 orphaned items. Worse, each
   unlink records (old_content, None), which the summary-patch step renders as
   "This memory content is discarded", so the LLM rewrites the category summary
   to drop the item too. Fix 8: when `categories` is None, rewrite the payload
   with the names of the item's current links, so memU's own diff removes
   nothing and records (old, new) instead. An explicit list still replaces and
   an explicit [] still clears. Preservation does not depend on `content` being
   supplied: a type-only update reaches the same diff and is covered.

2. update_item never refreshes extra.content_hash, which create_item_reinforce
   dedups on, so an updated item keeps its old text's hash forever: 149 of 149
   updated rows are hash-stale against a 400/400 fresh never-updated baseline.
   The consequence is measurable, not theoretical - correct an item's wording,
   re-memorize the old wording, and the corrected row is reinforced under its
   new text. Fix 9: recompute the hash from the DB row when summary or
   memory_type changes. Reading the ROW rather than get_item() is load-bearing:
   read paths build MemoryItem without extra=, so a cached item has extra == {}
   and a cache-based version would refresh nothing in any long-lived process
   while still passing a create-path test. Only a hash that already exists is
   refreshed, so an item created without one is not newly enrolled into dedup.

3. delete_item deletes the item row and leaves its memu_category_items rows
   behind - there is no FK and no ON DELETE CASCADE, and no layer owns the
   dependent rows. 6,455 dangling relations, and every one of the 5,611
   distinct dangling item_ids is in the item_deleted audit log. They also
   inflate memory_expand_category's reported total, which counts relations
   while listing through a JOIN, by 3.5 to 4.4 percent on every category.
   Fix 10: delete relations and the item in ONE transaction, so a failure can
   never leave a surviving item stripped of its links. Installed before Fix 3
   so its vector-index hook still wraps it. The cache and DatabaseState.relations
   are evicted on BOTH paths, as memU's own delete_item does: returning early
   for an already-deleted row would leave the id in self.items, and Fix 5 serves
   that cache unfiltered, so list_items() would keep returning a deleted item.
   No DB write is attempted when the row is absent.

The same investigation found the cause of a fourth, related population: Fix 7's
semantic-dedup writeback built `extra` from the item CACHE and assigned it over
the row's whole extra. Since read paths omit extra=, a reinforce through a cold
cache replaced the row's extra with just its own two salience keys, deleting
content_hash and any key another writer had added. All 6,473 rows with no
content_hash carry reinforcement_count > 1, none carry rc == 1, and the store
holds zero items of type "tool" - the only create path that legitimately writes
no hash - so that whole population is wipe damage. The writeback now seeds
`extra` from the row inside its existing transaction and refreshes the cache
from what was written, which is correct for every writer rather than only the
ones we know about: a raw-SQL sweep in this file adds extra.mentioned_at without
touching the cache, and that key is now preserved too.

All four patches live in _patch_sqlite_bugs(), the established seam for this
class of memu-py defect, which already carries seven numbered fixes.

Scope. This fixes the write paths only; it repairs no existing damage, and the
154 lost category memberships are not recoverable (the audit log records only
categories_changed: false, never the ids). Named but deliberately not fixed:
the read paths still omit extra= (no longer destructive now that the writeback
reads the row, so hydration is a separate change); clear_items has the same
dangling-relation shape but zero nerve callers; the in-memory and postgres repo
siblings and the in-memory reinforce arm are unreachable because the provider
is hardcoded sqlite; memu.app.patch.PatchMixin's duplicate handlers are dead
code. Relations left behind by an item another process already deleted are also
out of scope: a relations-only DELETE for a vanished item is a separate concern.
Tests pin each of those facts so an exemption cannot rot silently.

Behaviour changes worth noting: a reinforce now preserves more of extra,
including ref_id, which list_items_by_ref_ids filters on; hash-dedup now
matches rows whose hash used to be wiped, so some memorizations reinforce
instead of duplicating (which is the configured intent); and Fix 8 raises
rather than unlinking if an item's category ids do not round-trip through
ctx.category_name_to_id, which cannot happen through a supported path because
nerve rebuilds that map from every DB category on init.

Tests: 34 new tests in tests/test_memu_bridge.py. Each defect has a control arm
driving memU's own unpatched function against an identically-built fixture, so
a test that passes without the fix is a failed test rather than a passing fix.
A 16-mutant matrix over the four patches kills a test for every mutant, with a
no-op control that stays green. Two of those mutants pin properties an earlier
revision of these tests could not see: an item-first split of Fix 10's deletes
(the existing atomicity case forces the ITEM delete, which raises first in both
orderings, so a second case forces only the RELATIONS delete), and Fix 8 gated
on `content` (which a type-only update exposes). Full suite 2967 passed, with
the pre-existing failure set unchanged by name.

This message supersedes the test counts of the previous revision of this commit
(31 new tests, 13 mutants, 2964 passed), which predate the three cases added
here.
Three defects in the memu-py SQLite backend, each measured on the live store:

1. A content-only memory_update unlinks EVERY category. Omitting `categories`
   passes None, which memU maps to [] and diffs as "remove all". 154 of 154
   records updated without a categories argument now hold zero links - about
   47% of every orphan in the store.

2. memory_update never recomputes extra.content_hash, which create_item_reinforce
   dedups on, so an updated record can never again be recognised as a duplicate:
   149 of 149 updated items are hash-stale against a 400/400 fresh baseline.

3. memory_delete orphans category relations: there is no FK and no ON DELETE
   CASCADE, and no layer owns the dependent rows. 6,455 dangling relations,
   every one of the 5,611 distinct item_ids present in the item_deleted log.

A fourth defect surfaced while measuring the second: the semantic-dedup
writeback seeded `extra` from the ITEM CACHE and assigned it over the row's
whole `extra`. Read paths build MemoryItem without extra=, so a reinforce
through a cold cache deleted content_hash and every key another writer had
added. This explains the hashless population (all rc > 1, none rc == 1, and the
store holds 0 items of the one type that legitimately writes no hash).

All four are fixed in _patch_sqlite_bugs(), the established seam for this class
of memu-py defect - same tool and same layer as ClickHouse#119.

The hash refresh reads the row memU's write LEFT BEHIND rather than a pre-read
snapshot, and writes back through a single conditional UPDATE that is a no-op
unless summary and memory_type still hold what was just written. A snapshot
taken before the delegation closes its session before memU's write commits, so
a writer outside the memU loop thread (the date sweep runs on _blocking_pool
with its own connection; `nerve memory` is a second process) could otherwise
leave a hash of text the row does not hold. When the conditional write declines,
the cache is left alone rather than claiming a refresh that never landed.

Scope: write paths only. No existing data is repaired, and the 154 lost
memberships are not recoverable - the audit log records only
`categories_changed: false`, never the ids. An absent-row delete issues no DB
write at all, so a vanished item's own relation rows stay behind; repairing
those is out of scope here.

This revision supersedes the previous revision's counts: 39 new tests (not 34),
a 27-mutant matrix (not 16), full suite 2972 passed (not 2967). It also corrects
a claim that revision made: Fix 8's fail-closed raise is NOT unreachable through
a supported path. get_or_create_category filters on an exact name with no unique
index, while nerve's init rebuild keys on name.lower(), so two categories
differing only in case leave the displaced id absent from that map and an
omitted-category update on an item linked to it raises. The behaviour is still
correct - base silently unlinks the item where this raises and changes nothing -
but it is reachable, not unreachable.

Every claim above is backed by a control arm that drives memU's own unpatched
function, so a test that passes without the fix is a failed test rather than a
passing fix. Each mutant in the matrix kills at least one test, with a green
no-op control and zero vacuous mutations.
Three defects in the memu-py SQLite backend, each measured on the live store:

1. A content-only memory_update unlinks EVERY category. Omitting `categories`
   passes None, which memU maps to [] and diffs as "remove all". 154 of 154
   records updated without a categories argument now hold zero links - about
   47% of every orphan in the store.

2. memory_update never recomputes extra.content_hash, which create_item_reinforce
   dedups on, so an updated record can never again be recognised as a duplicate:
   149 of 149 updated items are hash-stale against a 400/400 fresh baseline.

3. memory_delete orphans category relations: there is no FK and no ON DELETE
   CASCADE, and no layer owns the dependent rows. 6,455 dangling relations,
   every one of the 5,611 distinct item_ids present in the item_deleted log.

A fourth defect surfaced while measuring the second: the semantic-dedup
writeback seeded `extra` from the ITEM CACHE and assigned it over the row's
whole `extra`. Read paths build MemoryItem without extra=, so a reinforce
through a cold cache deleted content_hash and every key another writer had
added. This explains the hashless population (all rc > 1, none rc == 1, and the
store holds 0 items of the one type that legitimately writes no hash).

All four are fixed in _patch_sqlite_bugs(), the established seam for this class
of memu-py defect - same tool and same layer as ClickHouse#119.

The hash refresh reads the row memU's write LEFT BEHIND rather than a pre-read
snapshot, and writes back through a single conditional UPDATE that is a no-op
unless summary and memory_type still hold what was just written. A snapshot
taken before the delegation closes its session before memU's write commits, so
a writer outside the memU loop thread (the date sweep runs on _blocking_pool
with its own connection; `nerve memory` is a second process) could otherwise
leave a hash of text the row does not hold. When the conditional write declines,
the cache is left alone rather than claiming a refresh that never landed.

Scope: write paths only. No existing data is repaired, and the 154 lost
memberships are not recoverable - the audit log records only
`categories_changed: false`, never the ids. An absent-row delete issues no DB
write at all, so a vanished item's own relation rows stay behind; repairing
those is out of scope here.

This revision supersedes the previous revision's counts: 44 new tests (not 39),
a 29-mutant matrix (not 27), full suite 2977 passed (not 2972). It also corrects
a claim that revision made: Fix 8's fail-closed raise is NOT unreachable through
a supported path. get_or_create_category filters on an exact name with no unique
index, while nerve's init rebuild keys on name.lower(), so two categories
differing only in case leave the displaced id absent from that map and an
omitted-category update on an item linked to it raises. The behaviour is still
correct - base silently unlinks the item where this raises and changes nothing -
but it is reachable, not unreachable.

The hash refresh is derived work and is best-effort: it runs after memU's
content write has committed, so a failure there (a lock, say) leaves the row
with a stale hash and a WARNING in the log rather than failing the update. That
is base's unconditional behaviour, whereas letting the exception escape would
report failure for an update that had already landed - with the categories
undiffed, since the handler diffs them after update_item returns, and the vector
index left on the old embedding. memU's own update call stays outside that
guard, so a genuine update failure still propagates.

One residual window is known and pinned by a test rather than claimed closed:
category preservation works by rewriting the payload from the links it reads,
and memU's handler re-reads them to build its diff, so a link created between
those two reads is still removed. Closing it would mean reimplementing that
diff, which would bypass category_updates and therefore the category-summary
step. Base loses a link in the same race and loses every link when there is no
race.

Every claim above is backed by a control arm that drives memU's own unpatched
function, so a test that passes without the fix is a failed test rather than a
passing fix. Each mutant in the matrix kills at least one test, with a green
no-op control and zero vacuous mutations.
This revision SUPERSEDES the figures stated in the previous commit message
(39 new tests, 26 mutant arms, 2977 passed): the counts below replace them.

memU's write paths damage the store in three measured ways. A content-only
memory_update unlinks EVERY category (154 of 154 items ever updated without a
categories argument held zero links), because memU maps a missing `categories`
to [] and so diffs the item's entire current set into cats_to_remove.
update_item never recomputes extra.content_hash, so 149 of 149 updated rows
carried a hash for text they no longer hold, which silently breaks the
hash-dedup create_item_reinforce relies on. delete_item removes the item row
and leaves its category relations behind (6,455 dangling).

Fix 8 no longer synthesizes a categories payload. An omitted `categories` now
performs no membership mutation at all: link/unlink are neutralised on the repo
instance for the delegated call and restored in a finally, and the (old, new)
pairs the LLM summary step consumes are rebuilt from the links that actually
survive. That closes the residual race the earlier snapshot-rewrite design
carried (a link inserted between memU's two reads was diffed away; it is now
preserved, pinned by a raw-SQL race) and deletes the fail-closed ValueError
along with the ctx.category_name_to_id inversion, so an incomplete name map is
harmless by construction rather than merely detected. An explicit list still
replaces and an explicit [] still clears, assertions byte-unchanged.

Fix 9 refreshes the hash under a CAS bound to summary/memory_type, taking the
new extra from RETURNING so a concurrent writer's key is never reverted in the
cache. The whole phase after memU's content write stays best-effort, since it
runs post-commit and must not turn a landed update into a raising call; the
swallowed failure is logged, and that log is now asserted rather than assumed.
A decode failure of the RETURNING payload no longer returns early: the CAS has
already committed by then, so skipping the cache assignments left the cache and
the returned item on the old hash while the row held the new one. Only the
decode is guarded, and its fallback is the row's content on that path because
the CAS bound summary/memory_type and set content_hash alone.

Fix 10 deletes the relations and the item in one transaction and prunes both
caches, so a failure on either side rolls the other back.

Validation: 48 targeted tests over the five write-path classes, 44 of them new;
full suite 2981 passed with the 7 failures that reproduce at a clean origin/main
export (6 TestResolveEventDatesSync + 1 test_telegram_sessions), failure sets
compared by name against the pre-edit baseline. Mutation matrix re-anchored from
scratch for this revision and re-run whole (nothing carried, because Fix 8 was
rewritten and the Fix 9 decode moved): 36 arms, 34 killed each naming at least
one failing test, 0 vacuous, and the only survivors are the two no-op controls,
green at both ends. Two Fix 8 arms lost their subject in the rewrite
(MI_fix8_fail_open has no raise to open, MG_fix8_mutates_ctx has no ctx read)
and are recorded as retired with the reason; equivalents were substituted for
the properties that remain, including an arm restoring the old snapshot shape,
which the retargeted residual test kills. link_item_category is measurably
unreachable on the omitted-categories path today, so stubbing it changes no
outcome; it is stubbed anyway so that "no membership mutation" holds by
construction, and a test observes that where it is made rather than by outcome.
ruff over both changed files is back to the 2 findings present at origin/main,
measured at every revision of this branch in one session.
This revision SUPERSEDES the figures stated in the previous commit message
(48 targeted tests, 44 new, 36 mutant arms, 2981 passed): the counts below
replace them, and Fix 8's mechanism is replaced for the second time.

memU's write paths damage the store in three measured ways. A content-only
memory_update unlinks EVERY category (154 of 154 items ever updated without a
categories argument held zero links), because memU maps a missing `categories`
to [] and so diffs the item's entire current set into cats_to_remove.
update_item never recomputes extra.content_hash, so 149 of 149 updated rows
carried a hash for text they no longer hold, which silently breaks the
hash-dedup create_item_reinforce relies on. delete_item removes the item row
and leaves its category relations behind (6,455 dangling).

Fix 8 neutralises membership for ONE CALL, on a per-call proxy, instead of
stubbing the repo instance. The previous revision assigned no-op link/unlink
onto store.category_item_repo and restored them in a finally. That repo is a
process-wide singleton, the delegated handler awaits an embedding call inside
the window, and nothing serializes memU calls onto its single loop, so the
no-ops were in force for every other coroutine for the duration of the await:
a concurrent membership write from any other pipeline was silently swallowed.
The restore was also not reentrant, so two overlapping updates could leak both
no-ops permanently. Now a relation-repo proxy whose two mutators are no-ops is
built inside the call and reached only through the `store` that call passes
down; the real store is handed back in the returned mapping so the proxy cannot
escape into persist_index or build_response. Nothing shared is written, so
there is no restore to get wrong.

The rebuilt category_updates now keeps only category ids the response step can
resolve. memU's _patch_build_response subscripts memory_category_repo.categories
unguarded and runs AFTER the content write commits, so an id it cannot resolve
raised KeyError post-commit and made bridge.update_item report False for an
update that had fully applied. memU could not reach that state (it derived ids
through _map_category_names_to_ids); rebuilding from raw relation rows can, and
this PR's own Defect 3 is that such rows accumulate.

Fix 9 refreshes the hash under a CAS bound to summary/memory_type, taking the
new extra from RETURNING so a concurrent writer's key is never reverted in the
cache. The whole phase after memU's content write stays best-effort, since it
runs post-commit and must not turn a landed update into a raising call; the
swallowed failure is logged, and that log is asserted rather than assumed. A
decode failure of the RETURNING payload does not return early: the CAS has
already committed by then. Only the decode is guarded, and its fallback is the
row's content on that path because the CAS bound summary/memory_type and set
content_hash alone.

Fix 10 deletes the relations and the item in one transaction and prunes both
caches, so a failure on either side rolls the other back. The relation DELETE is
no longer conditional on the item row still existing: when the row is already
gone (another process removed it) its relation rows ARE the dangling rows this
fix exists to prevent. Deleting the item stays conditional, and an unknown id
remains a silent no-op because the relation DELETE then matches nothing.

Validation: 51 targeted tests over the five write-path classes, all 51 new (none
of those five classes exists at origin/main; counted three ways -- added test
defs per class, pytest's own selection count, and the classes' absence at the
base -- all agreeing). The previous revision's "44 of them new" understated this.
Full suite 2984 passed with the 7 failures that reproduce at a clean origin/main
export (6 TestResolveEventDatesSync + 1 test_telegram_sessions), failure sets
compared by name against the pre-edit baseline and identical. The +3 selection
delta is exactly 5 new cases minus the 2 whose subject (instance stubs) no
longer exists; those two are deleted, not skipped.

Mutation matrix re-anchored from scratch and re-run whole, nothing carried: 38
arms, 35 killed each naming at least one failing test, 0 vacuous, the two no-op
controls green at both ends. Three arms lost their subject with the stubbing and
are recorded as retired with a named successor each. Two arms survive and both
are unobservable by construction, measured rather than assumed:
MN6_proxy_only_unlink drops the proxy's link override, and link_item_category is
unreachable on this path (with categories omitted mapped_new_cat_ids is [], so
cats_to_add is empty); MN10_known_read_off_proxy reads the categories mapping
off the proxy, which forwards it to the real store by identity
(memory_category_repo is the real object, only category_item_repo is overridden).

Two inherited mutant arms were found to be MIS-SITED against this tree and are
re-anchored. Both anchors began with whitespace and were applied by substring
replacement, so a hit count of 1 did not prove where they landed:
MY_residual_window_closed matched an indented explicit-list early return instead
of the delegation, splicing its payload into the wrong branch, which means its
kill in the previous revision was vacuous; MD_fix9_substitute_extra matched a
more deeply indented line inside a try (same statement and same effect, so it
still killed, but the siting was not proof). The mutator now asserts that every
anchor match begins at a line boundary, and that assertion catches both original
anchors while passing all 38 current arms.

ruff over both changed files is back to the 2 findings present at origin/main,
measured at both revisions in one session.
This revision SUPERSEDES the figures stated in the previous commit message
(53 targeted tests / 53 new, 41 mutant arms, 38 killed): the counts below
replace them. It also REVERTS that revision's Fix 7 ghost-eviction change --
see "Reverted in this revision" below. Fixes 8, 9 and 10 are unchanged.

memU's write paths damage the store in three measured ways. A content-only
memory_update unlinks EVERY category (154 of 154 items ever updated without a
categories argument held zero links), because memU maps a missing `categories`
to [] and so diffs the item's entire current set into cats_to_remove.
update_item never recomputes extra.content_hash, so 149 of 149 updated rows
carried a hash for text they no longer hold, which silently breaks the
hash-dedup create_item_reinforce relies on. delete_item removes the item row
and leaves its category relations behind (6,455 dangling).

Fix 8 neutralises membership for ONE CALL, on a per-call proxy, instead of
stubbing the repo instance. That repo is a process-wide singleton, the delegated
handler awaits an embedding call inside the window, and nothing serializes memU
calls onto its single loop, so instance-level no-ops were in force for every
other coroutine for the duration of the await. Now a relation-repo proxy whose
two mutators are no-ops is built inside the call and reached only through the
`store` that call passes down; the real store is handed back in the returned
mapping so the proxy cannot escape into persist_index or build_response. The
rebuilt category_updates keeps only category ids the response step can resolve,
because memU's _patch_build_response subscripts memory_category_repo.categories
unguarded AFTER the content write commits.

Fix 9 refreshes the hash under a CAS bound to summary/memory_type, taking the
new extra from RETURNING so a concurrent writer's key is never reverted in the
cache. The whole phase after memU's content write stays best-effort, since it
runs post-commit and must not turn a landed update into a raising call; the
swallowed failure is logged, and that log is asserted rather than assumed.

Fix 10 deletes the relations and the item in one transaction and prunes both
caches, so a failure on either side rolls the other back. The relation DELETE is
no longer conditional on the item row still existing: when the row is already
gone (another process removed it) its relation rows ARE the dangling rows this
fix exists to prevent.

Fix 7's semantic-dedup writeback seeds `extra` from the ROW, not from the cache.
Read paths build MemoryItem without extra=, so a cached extra is {} in any
process that did not itself write the item, and writing that back would drop
content_hash and every key another writer added.

Reverted in this revision: the previous revision also made that writeback evict
the cache and vector-index entry when the matched row was gone, instead of
completing a phantom reinforce. That remedy is already open, and semantically
identical, as PR ClickHouse#248 ("memory: do not report a semantic reinforce as successful
when the row is gone", created 2026-08-03T12:49:42Z, over three hours before the
review round that asked for it here). Both pop the id from self.items, remove it
from the index, resync seen_items_len, and fall through to the real create path
with a WARNING. Carrying it in two of our own open PRs means whichever merges
second must be resolved by hand, so this half is reverted and ClickHouse#248 carries the
fix and its five tests. `_semantic_sqlite_reinforce` and its in-memory sibling
are now byte-identical to the previous revision's parent (verified by AST
extraction: 57 and 36 lines, both exact; the same comparison against the
previous revision reports 57 vs 76, so it discriminates). No fix is lost.

Item 8's docstring summary also described the deleted payload-rewrite design
("rewrite the payload with the names of the item's current links"), which is the
race-prone mechanism an earlier revision removed; it now describes the per-call
proxy. A file-wide grep confirms no other prose describes the deleted mechanism.

Validation: 52 targeted tests over the five write-path classes, all 52 new (none
of those five classes exists at origin/main -- grepped, 0 hits each). Counted
three ways, all agreeing: 14 + 19 + 6 + 7 + 6 test defs per class; 128 collected
here against 76 at a clean origin/main export; 52 added `def test_` lines in the
diff. This revision removes one case (the ghost-match assertion, which tests
ClickHouse#248's contract) and keeps the case pinning, rather than closing, the window in
which a writer landing between memU's content write and Fix 9's post-read leaves
the cache entry and returned item carrying the row's hash over the caller's
summary. That window is harmless because the only SQLite-path consumer of
extra.content_hash queries the DB column, which the test asserts by re-deduping
on the row's own text; closing it means making the two writes atomic, which was
built in an earlier round and measured to store a WRONG hash on two correctness
paths.

Full suite 2985 passed with the 7 failures that reproduce at a clean origin/main
export (6 TestResolveEventDatesSync + 1 test_telegram_sessions); failure sets
compared BY NAME against this revision's own pre-edit baseline through the same
runner, diff empty. The 2986 -> 2985 delta is exactly the removed case. The kept
test also passes ALONE, the order-independence control that matters for a suite
whose fixtures patch process-global state.

Mutation matrix: 38 live arms = 35 killed (each naming at least one failing
test) + the 2 disclosed survivors below + the no-op control. 42 invocations: the
38 live arms, the 3 retired-with-cause arms, and the control repeated at the end.
0 vacuous, both no-op control arms green at both ends (52 passed), tree restored
byte-exactly after every arm (42 TREE_RESTORED_OK lines). Nothing was carried
blindly: the arms are imported from the r5 mutator (one source, not a retyped
copy) and every anchor is asserted to resolve exactly once against this tree
before the run (38/38, bad=0). The three arms the previous revision added for the
eviction branch are RETIRED WITH CAUSE, printed by name at run time rather than
dropped silently: their subject no longer exists in this PR, and their coverage
now lives in ClickHouse#248, whose tests assert both the cache and the index eviction. Two
arms the previous revision had re-anchored are de-re-anchored: the revert
restores their block byte-identically, so the r5 anchors are used verbatim. The
harness additionally asserts NEGATIVE markers -- the reverted text must be ABSENT
from every export -- so no arm can silently re-test the pre-revert code.

The two survivors are unchanged and were RE-MEASURED on this tree rather than
carried. MN6_proxy_only_unlink drops the proxy's link override, and
link_item_category is unreachable on this path: instrumented live,
_map_category_names_to_ids is called once with None and returns [], so
cats_to_add is empty and memU's link loop never runs.
MN10_known_read_off_proxy reads the categories mapping off the proxy, which
forwards it by identity: observed from inside the delegated call,
memory_category_repo is the real repo and .categories is the same object, while
category_item_repo is the one override.

Known residual, unchanged by this PR: this branch still conflicts with ClickHouse#248 in
both files, and did so before the reverted change existed (measured at every
commit on the branch: clean at the merge base, conflicting from the first
commit onward). The overlap is the surrounding Fix 7 `extra`-seeding edit, not
the reverted eviction. Not resolved here, because ClickHouse#248 is unmerged and stacking
on an unmerged prerequisite is its own hazard.

ruff over both changed files reports exactly the 2 findings present at
origin/main, measured at both revisions in one session. Non-ASCII in this
revision's added lines: 0.
@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author
Internal second-model review - 9 Gate B rounds, 55 findings adjudicated

Every change here was reviewed by a second model with no access to my reasoning, then
adjudicated against the code. Rounds are uncapped; this took nine, and the last five each
closed a defect the previous round had left behind.

Blockers found and fixed

# Finding What was wrong Fix
1 fix8-stale-snapshot Fix 8 rebuilt the category payload from a snapshot read before the write mechanism replaced
2 own-r4-stub-window-is-process-wide the replacement stubbed link/unlink on a process-wide repo instance across an await, so any concurrent pipeline's membership write was silently swallowed, and the restore was not reentrant replaced again, with a per-call proxy reachable only through the store that call passes down
3 own-r4-fabricated-category-updates-keyerror the rebuilt category_updates fed raw relation rows to a consumer that subscripts the category map unguarded, after the content write commits filter to resolvable ids
4 hash-writeback-atomicity the hash refresh could stamp a hash for text the row no longer held conditional UPDATE bound to summary/memory_type, new extra taken from RETURNING
5 category-noop-race the no-op window was observable by a concurrent caller per-call proxy (same fix as #2)
6 stale-row-ghost on a matched row another process deleted, the reinforce path incremented the cached copy and returned it with no DB write reverted from this PR - see below

Disagreed, with evidence

  • fix8-cold-category-cache (this round's only finding, a blocker). Claim: with a cold
    category cache the filter in Fix CLI sync, Telegram error handling, and memU max_tokens #3 drops a live id, so the persisted category summary stays
    stale. Refuted on a necessary step, with a control: memU's summary step reads the same
    cache the filter reads, via .get(cid) + continue (crud.py:651-653, again at :666).
    Modelled both arms - id uncached with the filter removed (so the id is in
    category_updates): update_category calls 0; same id cached (control): 1. In the
    cold state memU itself skips the summary whether or not the filter is present. Two further
    refutations: the response payload the filter shapes has no consumer (the bridge awaits
    the call and returns a bool, never reading category_updates); and the filter is
    load-bearing - removing it re-admits a KeyError from an unguarded subscript that runs
    after the content write commits, verified to raise for an id absent from the cache, on a
    store measured today to hold 6,455 dangling relations over 5,611 distinct item ids. The
    mechanism half is accepted as true (known is the cache, not the database); it is simply not
    a defect this PR introduces or can fix at that line.
  • hash-refresh-best-effort / hash-refresh-two-phase / fix9-best-effort-hash (raised
    four times). The prescribed remedy - make the content write and the hash refresh atomic - was
    built in an earlier round and measured to regress two correctness tests by storing a
    wrong hash, because memU's own write is what commits the content. Refused on that
    measurement, not on scope. The refresh stays best-effort and post-commit: if skipped, the row
    keeps its old hash, exactly today's behaviour. Logged, and asserted by a test that injects
    the lock.
  • date-sweep-clobbers-extra (raised twice). Mechanism confirmed and real, but its
    carrier is a different code path, byte-identical at the base, and outside this changelog.
    Escalated separately rather than absorbed here.
  • delete-link-race (raised three times). Mechanism real, but the inserting statement is
    memU's and this PR never modifies it, and at the base every delete dangles with no race at
    all. Already investigated to completion elsewhere, where the measurement was that 0 of
    6,455
    live dangling rows come from that path.
  • reinforce-extra-lost-update. Mechanism confirmed; the base performs the same
    read-modify-write, so this PR only shrinks the loss. The residual window is pinned by a test
    that first proves the interleaving happened, and disclosed in the body.

Reversals and corrections I made to my own work

  • stale-row-ghost is REVERTED in the final revision. I agreed it in an earlier round and
    wrote a fix plan for it; that was my error. The identical remedy was already open as
    memory: do not report a semantic reinforce as successful when the row is gone #248, created over three hours before the round that asked for it here, and carrying it in
    two PRs of mine would force a hand resolution on whichever merged second. The reverted
    functions are byte-identical to their pre-revert parent (AST extraction: 57 and 36 lines,
    with the same comparison against the reverted revision reporting 57 vs 76, so the comparator
    discriminates). memory: do not report a semantic reinforce as successful when the row is gone #248 carries the fix and five tests asserting both the cache and the index
    eviction, verified rather than assumed. No fix is lost.
  • My own validation comment claimed that reverted mechanism as shipped. Caught in this
    round's cold review and corrected before posting: the a-i comment's invariants row described
    the eviction as delivered, while the commit message on the same PR correctly said it was
    reverted. Measured on the shipped tree - zero occurrences of the eviction in the whole diff.
  • An exemption was described as comment-pinned when the same revert deleted that comment.
    Corrected on both surfaces: three of the named exemptions are pinned by tests, and the
    hardcoded-sqlite provider one is pinned by no test - stated plainly rather than
    implied.
  • Two inherited mutation-matrix arms were found mis-sited by substring matching, so one
    recorded kill had been vacuous. The mutator now asserts every anchor matches at a line
    boundary, proven in both directions.
  • Earlier bodies and commit messages carried figures that later rounds made stale (44, then 51,
    then 53 tests). Every figure in the final body was re-derived from the shipped tree, three
    independent ways where a count was involved.

Validation posture

52 targeted tests over five write-path classes, none of which exists at the base. A 38-arm
mutation matrix kills 35, each naming a failing test, with no-op controls green at both ends
and zero vacuous arms; the two survivors are unreachable by construction and were re-measured
on this tree rather than carried, and three arms whose subject the revert removed are retired
with cause, printed by name. Full suite 2985 passed, failure set identical by name to this
revision's own pre-edit baseline.

Known residual, disclosed rather than resolved: this branch conflicts with #248 in both
files, and did so from its first commit - before the reverted change existed (measured at every
commit: clean at the merge base, conflicting from the first commit on). The overlap is the
surrounding extra-seeding edit, not the reverted eviction. Not resolved here, because #248 is
unmerged and stacking on an unmerged prerequisite is its own hazard. #249 and #251 also
conflict, in the test file only, where all three append test classes at the end.

Session id: cron:clickhouse-review-slot-11:20260803-181700

@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Pre-PR validation gate

a-i self-check, with the measurement behind each answer
# Question Answer
a Deterministic repro? YES. A self-verdicting 14-arm script run against an unmodified main export (git archive, no .git) reports 9 arms DEFECT; against this branch 14/14 OK. 100% reproducible, not a percentage. Note memU is not constructible without the existing Fixes 1/2/6 (TypeError: Cannot create a consistent MRO, and memu's own sqlite_* table names are reserved by SQLite), so the control is _patch_sqlite_bugs() as it stands on main, i.e. production behaviour today.
b Root cause explained? YES. One sentinel for two meanings (crud.py:558-566 + :625-626); a derived field with no invalidation (memory_item_repo.py:311/:315-317 vs :388-460, where grep -c content_hash = 0); no layer owning the dependent rows (crud.py:593 -> :461-475, and the memu_category_items DDL has no FK); and cache-as-canonical in the Fix 7 writeback, since all four read paths (:73, :104, :149, :183) build MemoryItem without extra= while all four write paths pass it.
c Fix matches root cause? YES. Each patch sits in the function that owns the broken decision: the update handler that owns "was categories supplied?", the repo method that owns both the source columns and the derived field, the delete that owns its dependent rows, and canonical state read inside the write transaction. Fix 8's neutralisation is scoped to ONE CALL: a proxy built inside the call and reachable only through the store that call passes down, never a mutation of the process-wide repo instance, whose no-ops would otherwise be in force for every concurrent coroutine for the duration of the delegated await. No widened bounds, no guard over an unfixed cause. Two alternative shapes were rejected during planning with measurements - compensating after delegation leaves a (old, None) "discarded" signal, and a get_item()-based hash refresh is a production no-op - and mutants M2/M5 keep both rejections enforced.
d Test intent preserved / new tests? YES. No existing test weakened. One pre-existing test (TestIndexedUpdateItemForwarding) needed its global-state restore list widened and two attributes on its stub; its assertions are byte-unchanged. 52 new tests - none of the five write-path classes exists at origin/main, so every test in them is new; counted three ways (added test defs per class, pytest's own selection count, and the classes' absence at the base) all agreeing. This figure SUPERSEDES the earlier "44", "51" and "53"; re-measured three ways this round, all agreeing at 52 (14 + 19 + 6 + 7 + 6 per class; collected 128 vs 76 at the base; 52 added def test_ lines). This round REMOVES one case - the ghost-match eviction test, whose contract belongs to the already-open PR #248 that this round reverts a duplicate of - and keeps the case pinning the residual hash window described in row g. Nine tests the fix plan required unchanged were verified byte-identical by extracting each function body and comparing against the previous revision.
e Both directions? YES, three independent ways: the 9-arm delta above; per-defect control arms inside the suite that drive memU's own unpatched function against an identically-built fixture (test_unpatched_handler_loses_every_link, test_unpatched_update_leaves_the_hash_stale, test_unpatched_delete_orphans_the_relations); and a 38-arm mutation matrix (nothing carried blindly - the inherited arms are IMPORTED from the previous round's mutator and every anchor is asserted to resolve exactly once against this tree before the run), in which 35 arms are killed each naming at least one failing test while the no-op control stays green at BOTH ends (0 vacuous, tree verified restored after every arm, and every export asserted to CARRY this round's edits). The two survivors are unobservable BY CONSTRUCTION and measured rather than assumed, RE-MEASURED on this tree rather than carried: one drops a proxy override on a code path proven unreachable (instrumented live, _map_category_names_to_ids is called once with None and returns [], so cats_to_add is empty and memU's link loop never runs), the other reads a mapping the proxy forwards by object identity (observed from inside the delegated call: memory_category_repo is the real repo True, .categories is the same object True, and category_item_repo is real False - the one override). The three arms an earlier revision added for the ghost-eviction branch are RETIRED WITH CAUSE this round (printed by name at run time, never dropped silently): that branch is reverted here as a duplicate of PR #248, so their subject no longer exists in this PR, and their coverage now lives in #248 - verified rather than assumed, since #248 ships 5 tests asserting both the cache and the index eviction. The two arms that revision had re-anchored are DE-re-anchored: the revert restores their block byte-identically, so the r5 anchors are imported verbatim and all 38 are asserted to resolve exactly once. The harness also asserts NEGATIVE markers - the reverted text must be ABSENT from every export - so no arm can silently re-test pre-revert code. Two INHERITED arms were also found MIS-SITED - their whitespace-leading anchors matched more deeply indented lines, so a hit count of 1 did not prove where they landed, and one of them therefore recorded a vacuous kill in the previous revision; both are re-anchored and now genuinely kill, and the mutator asserts every anchor match begins at a line boundary (proven to catch both original anchors while passing all 38 current arms). The matrix earned its cost four times over: substituting extra instead of merging silently dropped a caller's ref_id; taking the hash's summary or type from the ARGUMENT rather than the row passes every non-racing case; a present-but-EMPTY hash is the one input where the Python guard and the SQL predicate disagree; and my own first version of the hash writeback stamped the cache even when the conditional write declined. Each is now pinned by a test. This round the matrix found the sharpest one yet: the ERROR CONTRACT of the hash phase was entirely unobserved - a mutant wrapping that whole phase in try/except Exception: return result was indistinguishable from the propagating shape, passing 39 of 39, so either contract satisfied the suite. Two cases now pin it in both directions. The hash-refresh restructure is also proven to discriminate against the shape it replaces: run against the previous two-session implementation, the new race test fails on its CORE assertion.
f General across code paths? YES. All three nerve call sites (agent tool, the bridge's own knowledge_filter delete, the web-UI routes) go through the patched bridge methods. Every sibling carrier is either fixed or named with the measurement that makes it unreachable: provider is hardcoded sqlite so the in-memory/postgres repos and the in-memory reinforce arm cannot be the backend; clear_memory has 0 nerve callers; PatchMixin has no subclass and is absent from MemoryService.__mro__. Tests pin the last two facts (test_clear_items_remains_unreachable_from_nerve, test_patch_mixin_duplicates_are_dead_code); the hardcoded-sqlite provider fact is pinned by NO test (grep -c provider tests/test_memu_bridge.py = 0) and this revision's revert also removed the source comment that named it, so that one exemption now rests on review alone. It is a MemoryService config value at memu_bridge.py:1864, changeable without touching either patched file.
g Generalizes across inputs? YES. All four configured memory types refresh correctly. Boundary summaries - empty, length 1, unicode, whitespace-only, 20,000 chars - all refresh correctly. categories as None / an explicit list / an explicit [] / an item with zero links all behave as intended, and the update SHAPE matrix covers content-only, type-only and content+type, the last now asserted directly - preservation does not depend on content being supplied (a mutant gating it on content survived 31 tests before a type-only case was added), and a conditional mutant that keeps the OLD type whenever a summary is supplied survived 36 tests before the combined case was added. Hash enrolment is measured across all three content_hash states: absent, JSON null, and present-but-empty. A missing id raises memU's own KeyError, unchanged. A non-round-tripping category map fails closed with every link still present. That former residual window is now CLOSED and pinned closed: membership is not mutated at all, so a category link inserted by raw SQL between the two reads survives and is reported to the summary step (test_a_link_added_between_the_two_reads_is_preserved). A different window IS disclosed rather than claimed closed: a third-party extra key written between the Fix 7 writeback's SELECT and its flush is lost, which base also loses (base loses every key, with no race at all), pinned by test_a_key_written_inside_the_writeback_window_is_still_lost and left for a separate CAS decision. A relation row naming a category id the response step cannot resolve is filtered out of category_updates, because memU subscripts that mapping unguarded AFTER the content write commits.
h Backward compatible? YES. git diff --name-only main is exactly two files; no schema, migration, config key, dependency or tool-schema change. Nothing depends on the destructive behaviour: none of the four call sites passes categories expecting a clear, and the tool handler's own "Nothing to update" guard shows the intent was always "update what was given". The three real behaviour changes are stated in the PR body rather than slipped in.
i Invariants preserved? YES. The invariant is "a write must not destroy state the caller did not ask to change, and must leave every derived artifact consistent". Verified across a create+update+delete+update sequence: 2 items, 6 relations, 0 dangling, and both in-process caches exactly matching the DB. Error paths covered in BOTH directions: forcing the item delete to raise leaves the relation rows AND the item row intact, and forcing only the relations delete to raise leaves the item row intact too (one session, since SQLiteSessionManager.session() returns a fresh Session per call). The second direction is the one that discriminates: session.delete raises first in both a one-transaction and an item-first-split form, so an item-first split survived 31 tests until a relations-only failure case was added. This PR does NOT change the semantic-dedup ghost path: an earlier revision evicted the stale cache and vector-index entry when the matched row was gone, and this revision REVERTS that, because the same remedy is already open as PR #248 (created three hours before the round that asked for it here). _semantic_sqlite_reinforce and its in-memory sibling are byte-identical to this branch's pre-revert parent, verified by AST extraction (57 and 36 lines; the same comparison against the reverted revision reports 57 vs 76, so it discriminates). Base's outcome on that path is unchanged by this PR, and #248 carries the fix plus five tests asserting both the cache and the index eviction. A missing id is a no-op for delete - the relation DELETE simply matches nothing - and a row another process already deleted still gets its cache entry and cached relations evicted AND its relation rows removed from the DB, asserted by raw count: on that path those rows ARE the dangling rows this fix exists to prevent, so the relation delete is no longer conditional on the item row (a mutant restoring that condition is killed by exactly the new raw-DB assertion). Fix 8 no longer raises at all, so there is no refused-update path left to reason about. The hash refresh holds the same invariant on EVERY exit path, including the one this round added: when its conditional write declines because another writer moved the row, nothing is written and the cache is left agreeing with the row rather than claiming a refresh that never landed (measured: db hash untouched, cache hash equal to it; a mutant dropping that gate leaves them disagreeing). No concurrency or lock-ordering contract is touched, and the added read is one indexed primary-key select on a rare human-initiated operation. The hash phase is also best-effort by contract, and that contract is now pinned rather than implied: it runs after memU's content write has committed, so a failure there leaves the row with a stale hash (base's unconditional behaviour) and a WARNING, while memU's own update call stays outside the guard so a genuine update failure still propagates. Both directions are asserted, and the shipped propagating shape is killed by the first case. Finally, the refreshed extra is taken from the write itself via RETURNING rather than rebuilt from the pre-write snapshot: json_set merges into the row's current extra while the write's predicate binds only summary/memory_type, so a concurrent writer's key (including reinforcement_count, which the salience ranking reads) survives in the row and must not be reverted in the cache. Measured both ways.

Also checked: the vector-index hook stays outermost and still fires; repeating
_patch_sqlite_bugs() does not double-wrap (verified over three consecutive calls); the
reinforcement_count > 1 decision that gates category linking after a reinforce is
unchanged - had it flipped, reinforced items would have stopped getting their links, i.e. a
new source of exactly the orphans this fixes.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@oranjeai

oranjeai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

cc @pufit @serxa — could you review this? It patches three memU write paths in _patch_sqlite_bugs(): a content-only memory_update currently unlinks every category (memU maps a missing categories to [], so its diff removes the item's whole current set), update_item never recomputes extra.content_hash that create_item_reinforce dedups on, and delete_item leaves its memu_category_items rows behind. Measured on the live store: 154/154 items updated without categories hold zero links, 149/149 updated rows carry their old text's hash, 6,455 dangling relations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants