What happens
Deleting a single note through the API/MCP path (DELETE /v2/.../entities/{id} → run_accepted_note_delete → delete_accepted_note) removes the deleted note's own search_index rows and vectors, but never touches the relation rows owned by notes that linked to it. Those rows keep to_id pointing at the deleted entity and a title like Alpha -> Beta, and search still returns them with a permalink to a note that no longer exists.
Found while investigating #1344; this is a separate gap and stays a gap after #1344 is fixed (see below).
Reproduction (current main, SQLite; same code path on Postgres)
write_note Alpha (body: "- links_to [[Beta]]")
write_note Beta
delete_note Beta # API / MCP single-note delete
search_index afterwards (sqlite3 memory.db):
id type entity_id to_id title permalink
1 entity 1 NULL Alpha demo/notes/alpha
1 relation 1 2 Alpha -> Beta demo/notes/alpha/links-to/demo/notes/beta <- entity 2 no longer exists
User-visible:
search_notes("Beta", entity_types=["relation"])
→ [{"type": "relation", "title": "Alpha -> Beta", "permalink": "demo/notes/alpha/links-to/demo/notes/beta", "file_path": "notes/Alpha.md"}]
Nothing schedules a refresh of Alpha, so the row stays until Alpha is edited or bm reindex runs. Under the consistency model that's a derived-state drift with no repair mechanism on the path that produced it.
Why only this path
Every other delete path already handles it. They snapshot the surviving relation sources before the entity delete and refresh those sources' search rows afterwards:
- watcher / external file delete:
relation_cleanup_sources_for_deleted_entity in indexing/external_file_delete_runner.py, refreshed by index/local_runtime.py (refresh_moved_entities(relation_cleanup_entity_ids))
- directory delete:
indexing/directory_delete_runner.py → services/directory_deletes.py (refresh_relation_sources)
- project scan deletes:
indexing/project_index_maintenance.py → project_index_runtime.py
delete_accepted_note (indexing/accepted_note_write_runner.py) is the one that just does delete_accepted_note_search_index + delete_accepted_note_vectors + session.delete(entity).
Relationship to #1344
Independent. #1344 changes relation.to_id from CASCADE to SET NULL so the source's relation row survives as unresolved. The source's search row would still say Alpha -> Beta with the old to_id until the source is refreshed. Recreating the target repairs it (forward-reference resolution refreshes the sources it resolves); deleting without recreating does not. So the API delete path needs the same refresh either way.
Proposed fix
In run_accepted_note_delete / delete_accepted_note, capture relation_cleanup_sources_for_deleted_entity(session, project_id=..., entity_id=...) before session.delete(entity) (same transaction, after the NoteContent lock — the same order the watcher path uses), carry the ids on the accepted change, and refresh them after commit through the existing refresh_moved_entities / index_entity hook, exactly as local_runtime.py does for external deletes. No new locking: it reads rows the delete already touches.
Regression test: API delete of a linked target → the source's relation search rows are gone (today) or show the unresolved title (after #1344), and search_notes(..., entity_types=["relation"]) no longer returns a permalink to the deleted note.
What happens
Deleting a single note through the API/MCP path (
DELETE /v2/.../entities/{id}→run_accepted_note_delete→delete_accepted_note) removes the deleted note's ownsearch_indexrows and vectors, but never touches the relation rows owned by notes that linked to it. Those rows keepto_idpointing at the deleted entity and a title likeAlpha -> Beta, and search still returns them with a permalink to a note that no longer exists.Found while investigating #1344; this is a separate gap and stays a gap after #1344 is fixed (see below).
Reproduction (current
main, SQLite; same code path on Postgres)search_indexafterwards (sqlite3 memory.db):User-visible:
Nothing schedules a refresh of
Alpha, so the row stays untilAlphais edited orbm reindexruns. Under the consistency model that's a derived-state drift with no repair mechanism on the path that produced it.Why only this path
Every other delete path already handles it. They snapshot the surviving relation sources before the entity delete and refresh those sources' search rows afterwards:
relation_cleanup_sources_for_deleted_entityinindexing/external_file_delete_runner.py, refreshed byindex/local_runtime.py(refresh_moved_entities(relation_cleanup_entity_ids))indexing/directory_delete_runner.py→services/directory_deletes.py(refresh_relation_sources)indexing/project_index_maintenance.py→project_index_runtime.pydelete_accepted_note(indexing/accepted_note_write_runner.py) is the one that just doesdelete_accepted_note_search_index+delete_accepted_note_vectors+session.delete(entity).Relationship to #1344
Independent. #1344 changes
relation.to_idfromCASCADEtoSET NULLso the source's relation row survives as unresolved. The source's search row would still sayAlpha -> Betawith the oldto_iduntil the source is refreshed. Recreating the target repairs it (forward-reference resolution refreshes the sources it resolves); deleting without recreating does not. So the API delete path needs the same refresh either way.Proposed fix
In
run_accepted_note_delete/delete_accepted_note, capturerelation_cleanup_sources_for_deleted_entity(session, project_id=..., entity_id=...)beforesession.delete(entity)(same transaction, after the NoteContent lock — the same order the watcher path uses), carry the ids on the accepted change, and refresh them after commit through the existingrefresh_moved_entities/index_entityhook, exactly aslocal_runtime.pydoes for external deletes. No new locking: it reads rows the delete already touches.Regression test: API delete of a linked target → the source's relation search rows are gone (today) or show the unresolved title (after #1344), and
search_notes(..., entity_types=["relation"])no longer returns a permalink to the deleted note.