Skip to content

fix(mcp): create missing edit_note notes at the path the identifier names - #1782

Merged
phernandez merged 5 commits into
mainfrom
fix/1775-edit-note-project-prefix
Oct 18, 2026
Merged

phernandez merged 5 commits into
mainfrom
fix/1775-edit-note-project-prefix

Conversation

@phernandez

@phernandez phernandez commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Fixes #1775. Supersedes #1776 by @ningyixi, whose commit is cherry-picked here with authorship kept, so the full CI runs on an in-repo branch.

The problem

edit_note append/prepend creates a missing note. Resolving an existing note already understood an identifier spelled with the project's prefix (demo/scratch/note, memory://demo/scratch/note), but the create path used the raw identifier, so it made a demo/ folder inside project demo.

For a plain path the create is genuinely ambiguous: demo/scratch/note can mean the note scratch/note spelled with its routing prefix, or a note inside a real folder named demo.

What changes

The path rules live in one place, server side, and folders are judged from storage.

  • services/note_placement.py owns the rules.
    • note_path_readings() lists every project-relative path an identifier names. A memory URL has one reading, since its leading segments routed to the project. A plain path below this project's prefix has two. Prefixes match by permalink and include the workspace-qualified form.
    • The link resolver now resolves through these same readings, so finding a note and creating one read identifiers identically.
  • place_missing_note() first checks that every reading stays inside the project, and refuses with an outside_project outcome before probing any folder; edit_note reports it as SECURITY_VALIDATION_ERROR. It then lets folders decide, judged from canonical note state: accepted notes (EntityRepository.directory_has_entities(), an exact prefix match, since the entity row exists at accept time) and files on storage (FileService.directory_has_files()). So a folder counts whether its notes are accepted but not yet materialized, or on disk but not yet indexed. The project root is no evidence, since it always exists.
    • Only one folder exists: create there.
    • Both exist: refuse with AMBIGUOUS_NOTE_PATH, naming the spelling that picks each.
    • Neither exists: permalinks_include_project decides.
  • QUERY /knowledge/placement returns the readings and either the create folder and title or the ambiguity.
  • edit_note keeps no path logic. It probes the returned readings for unindexed files (fixing Codex's P1 on fix(mcp): strip active project prefix on edit_note autocreate #1776), then creates where the server says.

Commits: @ningyixi's original fix; a first follow-up that read paths client side; and the server-side placement, which replaces it after the Codex review here showed the client's folder check read the index rather than storage.

Cloud follow-up

basicmachines-co/basic-memory-cloud#2565 adds S3FileService.directory_has_files() (a one-object prefix listing). Until Cloud has it, storage evidence on Cloud comes from the inherited local check, which finds no folders; accepted notes still count, and otherwise placement falls back to the permalink convention.

Tests

  • Unit tests for readings and placement in tests/services/test_note_placement.py.
  • directory_has_files() against storage: a nested file counts, an empty folder does not, and paths outside the project are refused.
  • directory_has_entities(): exact prefix match, so no sibling folder and no LIKE wildcard counts; also run on Postgres.
  • Project boundary: prefixed traversal (<project>/../../other/...) and extra-slash (<project>//tmp/...) identifiers are refused without probing, at the placement function, FileService, the /placement endpoint called directly, and edit_note end to end.
  • An accepted note whose file is not materialized yet still decides placement.
  • End-to-end edit_note tests: an unindexed file below the prefix is appended; a folder holding only an unindexed file wins; both folders existing refuses without creating anything. @ningyixi's two end-to-end tests pass unchanged.
  • just fast-check, and just fast-test (311 passed).

🤖 Generated with Claude Code

https://claude.ai/code/session_01KjsDUcHANBa17M84sKxFG3

ningyixi and others added 2 commits October 18, 2026 10:44
When append/prepend auto-creates a missing note from a project-qualified
identifier (permalink or memory:// URL), drop the active project slug
before deriving the on-disk path so files are not nested under an extra
project-named directory.

Fixes #1775

Signed-off-by: ningyixi <ningyixi@users.noreply.github.com>
A plain identifier that starts with the project's prefix is ambiguous
when it names a missing note: 'demo/scratch/note' is the note
'scratch/note' spelled with its routing prefix, or a note in a folder
named 'demo'. The first commit stripped a literal permalink prefix
before create; this replaces that with one model of the readings.

- note_path_readings() lists every project-relative path the identifier
  names: one for a memory URL (its leading segments routed), two for a
  plain path below this project's prefix. Prefixes match by permalink
  and include the workspace-qualified form, as routing does.
- Disk recovery probes every reading, so an unindexed file below the
  prefix is edited instead of colliding with a create (Codex P1).
- choose_note_create_path() lets existing folders decide; the project
  root is no evidence. Both folders holding notes refuses with
  AMBIGUOUS_NOTE_PATH and the spelling that picks each. With no
  evidence, permalinks_include_project decides, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjsDUcHANBa17M84sKxFG3
Signed-off-by: phernandez <paul@basicmachines.co>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-18T17:07:19.402986Z fd46474 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 87cd8cf203

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/basic_memory/mcp/tools/edit_note.py Outdated
The previous commit read identifier paths in edit_note and judged
folders through /directory/list, which is built from entity rows. A
folder whose notes were on disk but not yet indexed read as empty, so a
note could land below the prefix and never be relocated. And the path
rules now lived in three places: the link resolver, MCP routing, and
edit_note.

- services/note_placement.py owns the rules. note_path_readings() lists
  every project-relative path an identifier names; the link resolver
  and placement both use it, with the request's workspace from
  workspace_slug_for_canonical_permalinks().
- place_missing_note() judges folders through
  FileService.directory_has_files(), which reads storage, so unindexed
  files count.
- QUERY /knowledge/placement returns the readings and either the create
  folder and title or the ambiguity. edit_note probes the returned
  readings for unindexed files and creates where the server says; it
  keeps no path logic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjsDUcHANBa17M84sKxFG3
Signed-off-by: phernandez <paul@basicmachines.co>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f4eddfb4f7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/basic_memory/api/v2/routers/knowledge_router.py Outdated
Comment thread src/basic_memory/services/file_service.py Outdated
phernandez and others added 2 commits October 18, 2026 11:54
Review follow-up for #1782. An identifier like
'demo/../../other/secrets' or 'demo//tmp/x' produced readings outside
the project, and /placement walked those directories before edit_note's
path check ran: an existence oracle across project boundaries, and a
blocking scan on a large tree.

- place_missing_note() checks every reading against the project before
  probing any folder and returns OutsideProjectPlacement for the first
  one that escapes. edit_note maps it to SECURITY_VALIDATION_ERROR
  before disk recovery; its own directory check is gone.
- FileService.directory_has_files() refuses paths outside the project
  and walks off the event loop.

Tests cover the prefixed-traversal and extra-slash cases at the
placement function, FileService, the endpoint called directly, and
edit_note end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjsDUcHANBa17M84sKxFG3
Signed-off-by: phernandez <paul@basicmachines.co>
Review follow-up for #1782. Production accepts a write before it
materializes the file, so a folder holding only a just-accepted note is
not on storage yet. Placement judged by storage alone sent the next
note in that folder below the prefix, and materialization never moves
it back.

Folder evidence is now canonical note state: accepted notes, through
EntityRepository.directory_has_entities() (an exact prefix compare, not
LIKE), then files on storage. The index alone missed unindexed files;
storage alone missed accepted ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjsDUcHANBa17M84sKxFG3
Signed-off-by: phernandez <paul@basicmachines.co>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fd46474ef9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/basic_memory/api/v2/routers/knowledge_router.py
@phernandez
phernandez merged commit 9655393 into main Oct 18, 2026
39 checks passed
phernandez added a commit that referenced this pull request Oct 18, 2026
Review follow-up for #1782. An identifier like
'demo/../../other/secrets' or 'demo//tmp/x' produced readings outside
the project, and /placement walked those directories before edit_note's
path check ran: an existence oracle across project boundaries, and a
blocking scan on a large tree.

- place_missing_note() checks every reading against the project before
  probing any folder and returns OutsideProjectPlacement for the first
  one that escapes. edit_note maps it to SECURITY_VALIDATION_ERROR
  before disk recovery; its own directory check is gone.
- FileService.directory_has_files() refuses paths outside the project
  and walks off the event loop.

Tests cover the prefixed-traversal and extra-slash cases at the
placement function, FileService, the endpoint called directly, and
edit_note end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjsDUcHANBa17M84sKxFG3
Signed-off-by: phernandez <paul@basicmachines.co>
@phernandez
phernandez deleted the fix/1775-edit-note-project-prefix branch October 18, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants