Skip to content

Commit cae7a4b

Browse files
phernandezclaude
andcommitted
fix(core): reject local note creates over existing unindexed files
A DB-first create whose target file already existed on disk but was not yet indexed (manual write, DB reset, or watcher lag) committed new entity, note_content, and search rows, then materialization recorded external_change_detected without overwriting the file or rolling back. The caller saw "Created" while the markdown kept old content and DB/search described new content, and the next watcher pass would overwrite the DB with the stale file, silently losing the write. Local runtimes (filesystem is the source of truth) now keep the storage existence check on creates via a new verify_storage_absent_on_create dependency flag, rejecting with 409 instead of diverging. The previously uncaught EntityAlreadyExistsError is mapped to a typed conflict rejection. Cloud keeps DB-first acceptance (flag defaults to False) and reconciles object storage during materialization. Addresses the #1002 review comment on accepted_note_mutation_runner create. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 96845b0 commit cae7a4b

3 files changed

Lines changed: 52 additions & 1 deletion

File tree

src/basic_memory/deps/services.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,10 @@ async def get_note_content_mutation_service(
706706
disable_permalinks=app_config.disable_permalinks,
707707
update_permalinks_on_move=app_config.update_permalinks_on_move,
708708
),
709+
# Local filesystem is the source of truth: reject a create when the
710+
# target file already exists on disk but is not yet indexed (#1002
711+
# review), rather than diverging DB/search from the file.
712+
verify_storage_absent_on_create=True,
709713
),
710714
content_freshener=LocalCurrentNoteContentFreshener(
711715
entity_repository=entity_repository,

src/basic_memory/indexing/accepted_note_mutation_runner.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
)
3030
from basic_memory.models import Entity, NoteContent, Project
3131
from basic_memory.repository import NoteContentRepository
32+
from basic_memory.services.exceptions import EntityAlreadyExistsError
3233
from basic_memory.repository.accepted_note_search_repository import AcceptedNoteSearchRepository
3334
from basic_memory.repository.entity_repository import EntityRepository
3435
from basic_memory.runtime import (
@@ -290,6 +291,14 @@ class AcceptedNoteMutationDependencies:
290291
write_repositories: AcceptedNoteWriteRepositories
291292
clock: AcceptedNoteMutationClock
292293
move_policy: AcceptedNoteMutationMovePolicy
294+
# Trigger: local runtimes where the filesystem is the source of truth.
295+
# Why: a DB-first create over a file that exists on disk but is not yet
296+
# indexed would commit new DB/search rows while the file keeps old content;
297+
# the next watcher pass then overwrites the DB with the stale file content,
298+
# silently losing the write. Cloud reconciles object storage during
299+
# materialization, so it keeps DB-first acceptance.
300+
# Outcome: local creates reject the conflict up front (409) instead.
301+
verify_storage_absent_on_create: bool = False
293302

294303

295304
def accepted_note_integrity_rejection(error: IntegrityError) -> AcceptedNoteMutationRejection:
@@ -451,9 +460,14 @@ async def _run_accepted_note_create(
451460
prepared_write = await prepare_accepted_note_create(
452461
preparer,
453462
request.data,
454-
check_storage_exists=False,
463+
check_storage_exists=dependencies.verify_storage_absent_on_create,
455464
session=session,
456465
)
466+
except EntityAlreadyExistsError as error:
467+
# An unindexed file already occupies this path (local source-of-truth
468+
# runtimes only). Reject instead of committing DB state that the next
469+
# watcher pass would overwrite with the stale file content.
470+
reject_accepted_note_mutation(AcceptedNoteMutationRejectKind.conflict, str(error))
457471
except (ParseError, ValueError) as error:
458472
reject_accepted_note_mutation(AcceptedNoteMutationRejectKind.bad_request, str(error))
459473

tests/api/v2/test_knowledge_router.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,39 @@ async def test_create_entity_conflict_returns_409(client: AsyncClient, v2_projec
386386
assert response.json()["detail"] == expected_detail
387387

388388

389+
@pytest.mark.asyncio
390+
async def test_create_entity_rejects_existing_unindexed_file(
391+
client: AsyncClient, v2_project_url, project_config
392+
):
393+
"""A create over a file that exists on disk but is not indexed returns 409.
394+
395+
Regression for the #1002 review: local creates must not commit DB/search
396+
rows that diverge from on-disk content, which the next watcher pass would
397+
restore (silently losing the write). The local runtime keeps the storage
398+
existence check enabled, so the create is rejected up front.
399+
"""
400+
file_path = project_config.home / "conflict" / "UnindexedNote.md"
401+
file_path.parent.mkdir(parents=True, exist_ok=True)
402+
original = "---\ntitle: UnindexedNote\ntype: note\n---\n\nPre-existing on-disk content\n"
403+
file_path.write_text(original, encoding="utf-8")
404+
405+
response = await client.post(
406+
f"{v2_project_url}/knowledge/entities",
407+
json={
408+
"title": "UnindexedNote",
409+
"directory": "conflict",
410+
"note_type": "note",
411+
"content_type": "text/markdown",
412+
"content": "New content from the create request",
413+
},
414+
params={"fast": False},
415+
)
416+
417+
assert response.status_code == 409
418+
# The on-disk file must be untouched: no divergence, no silent overwrite.
419+
assert file_path.read_text(encoding="utf-8") == original
420+
421+
389422
@pytest.mark.asyncio
390423
async def test_create_entity_returns_content(client: AsyncClient, file_service, v2_project_url):
391424
"""Test creating an entity always returns file content with frontmatter."""

0 commit comments

Comments
 (0)