What happens
write_note(..., overwrite=True) creates a second note instead of replacing the existing one, when the existing note's file has been renamed or moved. It reports # Created note and no error.
The result is two notes with the same title in the same directory. Everything that reads the note by path, or by listing the directory, keeps reading the moved file, so the edit appears to have been made and changes nothing. The caller is told the write succeeded.
Reproduction
main at 6d6e7fe53c53d2c1d8e034953ef3a82be25251c8 (2026-09-05), SQLite, default config — specifically update_permalinks_on_move: false, which is the shipped default. Only public MCP tools are used.
await write_note(title="song-sketching", directory="app/probe",
content="# original\nfirst body")
await move_note(identifier="song-sketching",
destination_path="app/probe/SKILL.md")
await write_note(title="song-sketching", directory="app/probe",
content="# replacement\nsecond body", overwrite=True)
Literal output:
--- STEP 1 write_note (create) ---
# Created note
file_path: app/probe/song-sketching.md
permalink: test-project/app/probe/song-sketching
--- STEP 2 move_note ---
Note moved successfully
song-sketching -> app/probe/SKILL.md
Permalink: test-project/app/probe/song-sketching
--- STEP 3 write_note(overwrite=True) ---
# Created note
file_path: app/probe/song-sketching.md
permalink: test-project/app/probe/song-sketching-1
--- STEP 4 list_directory ---
Contents of '/app/probe' (depth 1):
Page 1 (page size 10, 2 total items)
SKILL.md app/probe/SKILL.md | song-sketching | id: 85fba0a0-...
song-sketching.md app/probe/song-sketching.md | song-sketching | id: 0ebd2edd-...
Total: 2 items (2 files)
--- STEP 5 files on disk ---
['SKILL.md', 'song-sketching.md']
--- STEP 6 read_note("app/probe/SKILL.md") ---
---
title: song-sketching
type: note
permalink: test-project/app/probe/song-sketching
---
# original
first body
Step 2 is the interesting one. move_note was given the bare string song-sketching and found the note — the API log records resolution_method: 'title'. So resolving this note by title, after the move, already works and is already in the codebase. write_note just doesn't ask.
Step 6 is the part that costs a user something: the note anyone actually reads still holds the original body. The replacement went into a file nothing points at.
Cause
Traced on the commit above.
write_note does an optimistic create and treats a 409 as "exists, switch to update" (src/basic_memory/mcp/tools/write_note.py). The 409 originates in prepare_create_entity_content, from file_service.exists(file_path) (src/basic_memory/services/note_preparation.py:391). file_path is derived from the title and directory, not looked up by identity.
- After the move,
app/probe/song-sketching.md does not exist, so there is no 409, so the overwrite branch is never entered at all. overwrite=True and overwrite=False produce the same thing here.
- The new note's permalink then collides with the moved note's, which still holds
test-project/app/probe/song-sketching because update_permalinks_on_move defaults to false. The suffix loop in resolve_permalink (note_preparation.py:274-285) appends -1.
That loop already guards the neighboring case, and its comment names it:
# A case-only rename resolves to the slug the entity already holds;
# suffixing it would churn `config` -> `config-1` on every move (#1281).
if owner is None or owner == current_file_path:
break
This case slips past because on the create branch there is no current_file_path to compare against — the caller never identified an existing row.
Why this reads as a bug rather than a design choice
The suffixing itself may well be intentional, and I am not arguing against it. Two notes cannot hold one permalink, and -1 is a reasonable way to break the tie once you have decided to create a second note.
What does not look defensible is that overwrite=True silently does not overwrite.
- The parameter's whole job is this decision.
#818 established that an explicit overwrite=True must not be dropped, and write_note's own docstring says it replaces an existing note. Here the flag is read, resolved against the config default, and then never consulted, because the code path that consults it is gated on a filesystem check that the move defeats.
- The end state the caller asked for is already reachable.
move_note resolved the same note from the same title one call earlier. Nothing about the note is unfindable; the create path just uses a different key.
- There is no signal. No error, no warning, no changed wording in the response — the tool says
# Created note in both the ordinary create case and this one. #1077 made the same complaint about a log-only warning that MCP callers never see; here there isn't even a log line, because detect_file_path_conflicts looks for filename-convention variants of the same title and a rename to an unrelated name is not one.
- A duplicate wins nothing. If the goal were to protect the existing note, refusing would do that and would tell the caller. Creating a shadow copy protects the note while hiding the fact that it was protected.
Existing issues I checked
#860 (merged) fixes the resolution inside the overwrite branch — resolve the conflicting entity by file_path strictly rather than by fuzzy permalink. Different problem: it assumes the 409 fired. Here it does not.
#1077 / #1078 cover a duplicate created when the existing file uses a different filename convention for the same title (kebab-case vs Title Case). Same symptom, different trigger — that one is about how the path is derived, this one is about the file having been moved out from under the derived path. #1078 was closed unmerged.
#1281 is the case-only rename the suffix guard above was written for.
#1326 is directory-name case resolution.
#818 is the opposite symptom: overwrite=True refusing a write it should have made.
#1259 proposes semantic dedup before a write, which is a different question from identity.
I could not find this reported. Apologies if I missed it.
Two ways to fix it, and I would rather ask than assume
A — replace by identity. Before taking the create branch, look the note up the way move_note already does. If a note with that title exists in that directory under a different filename, and overwrite=True, update it where it now lives.
Sharper for the caller, but it means write_note can write to a path it was not given. If someone moved a note deliberately, a later write silently following it may not be what they wanted either.
B — refuse, and say where it went. Detect that a note with this title and directory exists at a different path, and return an error naming that path, in the shape of the existing _format_overwrite_error message. The caller decides: write to the moved file, or create a genuinely new note.
Safer and much smaller. Costs a round trip in the common case, where following the move is probably what was meant.
There is a plausible third position — that the duplicate is correct because the title-and-directory pair no longer names that note once it has been renamed — but then overwrite=True should say so rather than reporting a plain create.
I have not written a patch. Happy to, once you say which behavior you want; I did not want to arrive with a design.
What happens
write_note(..., overwrite=True)creates a second note instead of replacing the existing one, when the existing note's file has been renamed or moved. It reports# Created noteand no error.The result is two notes with the same title in the same directory. Everything that reads the note by path, or by listing the directory, keeps reading the moved file, so the edit appears to have been made and changes nothing. The caller is told the write succeeded.
Reproduction
mainat6d6e7fe53c53d2c1d8e034953ef3a82be25251c8(2026-09-05), SQLite, default config — specificallyupdate_permalinks_on_move: false, which is the shipped default. Only public MCP tools are used.Literal output:
Step 2 is the interesting one.
move_notewas given the bare stringsong-sketchingand found the note — the API log recordsresolution_method: 'title'. So resolving this note by title, after the move, already works and is already in the codebase.write_notejust doesn't ask.Step 6 is the part that costs a user something: the note anyone actually reads still holds the original body. The replacement went into a file nothing points at.
Cause
Traced on the commit above.
write_notedoes an optimistic create and treats a 409 as "exists, switch to update" (src/basic_memory/mcp/tools/write_note.py). The 409 originates inprepare_create_entity_content, fromfile_service.exists(file_path)(src/basic_memory/services/note_preparation.py:391).file_pathis derived from the title and directory, not looked up by identity.app/probe/song-sketching.mddoes not exist, so there is no 409, so theoverwritebranch is never entered at all.overwrite=Trueandoverwrite=Falseproduce the same thing here.test-project/app/probe/song-sketchingbecauseupdate_permalinks_on_movedefaults to false. The suffix loop inresolve_permalink(note_preparation.py:274-285) appends-1.That loop already guards the neighboring case, and its comment names it:
This case slips past because on the create branch there is no
current_file_pathto compare against — the caller never identified an existing row.Why this reads as a bug rather than a design choice
The suffixing itself may well be intentional, and I am not arguing against it. Two notes cannot hold one permalink, and
-1is a reasonable way to break the tie once you have decided to create a second note.What does not look defensible is that
overwrite=Truesilently does not overwrite.#818established that an explicitoverwrite=Truemust not be dropped, andwrite_note's own docstring says it replaces an existing note. Here the flag is read, resolved against the config default, and then never consulted, because the code path that consults it is gated on a filesystem check that the move defeats.move_noteresolved the same note from the same title one call earlier. Nothing about the note is unfindable; the create path just uses a different key.# Created notein both the ordinary create case and this one.#1077made the same complaint about a log-only warning that MCP callers never see; here there isn't even a log line, becausedetect_file_path_conflictslooks for filename-convention variants of the same title and a rename to an unrelated name is not one.Existing issues I checked
#860(merged) fixes the resolution inside the overwrite branch — resolve the conflicting entity byfile_pathstrictly rather than by fuzzy permalink. Different problem: it assumes the 409 fired. Here it does not.#1077/#1078cover a duplicate created when the existing file uses a different filename convention for the same title (kebab-case vs Title Case). Same symptom, different trigger — that one is about how the path is derived, this one is about the file having been moved out from under the derived path.#1078was closed unmerged.#1281is the case-only rename the suffix guard above was written for.#1326is directory-name case resolution.#818is the opposite symptom:overwrite=Truerefusing a write it should have made.#1259proposes semantic dedup before a write, which is a different question from identity.I could not find this reported. Apologies if I missed it.
Two ways to fix it, and I would rather ask than assume
A — replace by identity. Before taking the create branch, look the note up the way
move_notealready does. If a note with that title exists in that directory under a different filename, andoverwrite=True, update it where it now lives.Sharper for the caller, but it means
write_notecan write to a path it was not given. If someone moved a note deliberately, a later write silently following it may not be what they wanted either.B — refuse, and say where it went. Detect that a note with this title and directory exists at a different path, and return an error naming that path, in the shape of the existing
_format_overwrite_errormessage. The caller decides: write to the moved file, or create a genuinely new note.Safer and much smaller. Costs a round trip in the common case, where following the move is probably what was meant.
There is a plausible third position — that the duplicate is correct because the title-and-directory pair no longer names that note once it has been renamed — but then
overwrite=Trueshould say so rather than reporting a plain create.I have not written a patch. Happy to, once you say which behavior you want; I did not want to arrive with a design.