Skip to content

[BUG] replace_section silently preserves content past the next h2 when replacement adds new h2 headings #1012

Closed

Description

@phernandez

Summary

replace_section on a heading replaces from that heading to the next same-or-higher-level heading in the original file. If the replacement content includes new h2 headings that match later section names in the original, those original sections get preserved after the new content — which silently duplicates content.

Repro

A ticket note like this:

## Observations
- [status] open
- [priority] high

## Description
Jamie called in...

## Relations
- reported_by [[Jamie Rivera]]

Call:

edit_note(
  identifier="...",
  operation="replace_section",
  section="## Observations",
  content="- [status] resolved\n- [resolution] ...\n\n## Description\nUpdated...\n\n## Resolution\nFix details...",
)

Actual result

## Observations
- [status] resolved
- [resolution] ...

## Description    <- from replacement
Updated...

## Resolution    <- from replacement
Fix details...
## Description    <- ORIGINAL, still here
Jamie called in...

## Relations
- reported_by [[Jamie Rivera]]

The original ## Description survives because replace_section stopped at the original ## Description heading and resumed after it, but my new content also had a ## Description heading. Result: duplicate sections.

Expected

One of:

  1. Detect h2s in the replacement and refuse / warn ("replacement content contains '## Description' which would conflict with existing section — use overwrite or separate calls").
  2. Replace through all collided headings in the original.
  3. Document this behavior clearly in the replace_section description so callers know to use write_note(overwrite=True) for multi-section rewrites.

Workaround

Use write_note with overwrite=true for any edit that crosses section boundaries.

Discovered while

Building a help-desk demo for basicmemory.com — agents resolving tickets need to rewrite multiple sections at once, and this trap is easy to fall into without realizing.

Activity

  1. claude commented on Jun 23, 2026

    @claude
    Contributor

    Claude finished @phernandez's task in 2m 30s —— View job


    Triage Analysis

    • Gather context and read issue
    • Classify issue type, priority, component, complexity
    • Search for duplicates
    • Investigate the replace_section code
    • Add appropriate labels (bug added; other labels not yet created in repo)
    • Post triage summary

    Classification

    Field Value
    Type Bug
    Priority Medium — silent data duplication; workaround exists (write_note with overwrite=true)
    Component MCP — edit_note tool, replace_section operation
    Complexity Medium — root cause is clear; fix requires care not to break subsection-preservation behavior
    Duplicates None found

    Root Cause

    The bug is in entity_service.py:replace_section_content (lines 1267–1353). The section-skip loop (lines 1338–1345) stops consuming original lines at the first # heading of any level:

    # Skip the original section content until next header or end
    while i < len(lines):
        next_line = lines[i]
        # Stop consuming when we hit any header (preserve subsections)
        if next_line.startswith("#"):
            break        # <-- stops at ## Description in original
        i += 1
    # Continue processing from the next header
    continue

    When the replacement content also includes ## Description, the result is:

    1. Replacement content's ## Description is emitted (it was already appended as part of new_content)
    2. The loop stops at the original ## Description (because it starts with #)
    3. The original ## Description and everything after it is then also emitted

    This gives two ## Description sections.


    Recommended Fix

    Option 1 (defensive, safest): detect headings in new_content that collide with headings in the remaining original content, and raise a ValueError with a clear message directing the caller to use write_note instead:

    ValueError: replacement content contains '## Description' which already exists after 
    this section in the document. Use write_note(overwrite=True) for multi-section rewrites.
    

    This preserves the current skip-at-any-heading behavior (which protects subsections), adds no new edge cases, and gives callers an actionable error rather than silent corruption.

    Option 2 (smarter boundary): When skipping original content, only skip up to the next heading of same or higher level as the target section (e.g., for ## Foo, skip until the next ## or #). This is standard markdown section semantics but would change existing behavior for docs that rely on sub-heading preservation.

    Option 3 (docs-only): Document the limitation clearly in the edit_note tool description. Lower effort but doesn't prevent silent data loss.

    My recommendation: Option 1. It's the safest change, prevents silent corruption, and the error message steers users to the correct tool.


    Files to Change

    • src/basic_memory/services/entity_service.py — replace_section_content (lines ~1338–1345): add collision detection before the replacement loop
    • tests/services/test_entity_service.py — add a test case for the collision scenario
    • Tool description in src/basic_memory/mcp/tools/edit_note.py — note the single-section scope of replace_section
  2. groksrc commented on Jun 26, 2026

    @groksrc
    Member

    Adding a second, simpler manifestation of this same root cause — and a correction to the mechanism described above.

    The issue body says replace_section "stops at the next same-or-higher-level heading." It actually stops at the next heading of any level — entity_service.py:1336:

    if next_line.startswith("#"):
        break

    Minimal repro (no h2 collision in the replacement needed):

    ## Section A
    intro text
    ### Sub A1
    body
    ### Sub A2
    body
    ## Section B

    Call edit_note(identifier, "replace_section", section="## Section A", content="<rewritten Section A>").

    Expected: Section A and its subsections A1/A2 are replaced, up to ## Section B.

    Actual: only the text between ## Section A and ### Sub A1 is replaced; ### Sub A1 / ### Sub A2 are left orphaned after the new content. When the rewritten content reintroduces those subsections, you get silent duplication.

    The current behavior is documented as intentional ("preserve subsections"), so this is really a missing capability: there's no way to replace a section including its descendants in one call, and the silent partial-replace is surprising.

    Proposed fix (level-aware boundary): parse the target heading's level and only break on a heading of the same or higher level:

    section_level = len(section_header) - len(section_header.lstrip("#"))
    while i < len(lines):
        stripped = lines[i].lstrip()
        if stripped.startswith("#"):
            next_level = len(stripped) - len(stripped.lstrip("#"))
            if next_level <= section_level:   # same-or-higher level → real boundary
                break
        i += 1

    Since this reverses a deliberate design choice, an alternative is to keep the current default and add an explicit include_subsections: bool (or depth) parameter — or, at minimum, detect the orphaning and warn. Happy to send a PR for whichever direction you prefer.

  3. phernandez commented on Jul 15, 2026

    @phernandez
    MemberAuthor

    Status: confirmed on main. replace_section_content (entity_service.py) stops consuming original content at a heading of any level, so the original next-h2 section (and subsections) are re-emitted after the replacement — that's the silent duplication/orphaning. groksrc's mechanism description is the accurate one; the earlier "same-or-higher" framing was off.

    Worth noting this is currently deliberate: the "stop at any header, preserve subsections" behavior is documented in the docstring and locked by test_edit_entity_replace_section_with_subsections, so a fix changes an intentional contract. Plan: keep subsection-protection as the default, but detect the same-heading collision case (re-emitting a heading the replacement already contains is never "preserving a subsection") and handle it explicitly, add an opt-in for full-section rewrites, and state the single-section scope clearly in the edit_note tool docs.

  4. phernandez commented on Jul 15, 2026

    @phernandez
    MemberAuthor

    Decision (supersedes the plan in the previous comment): replace_section becomes level-aware by default. Replacing an h2 section consumes everything through the next h2 (or h1) in the original document — including h3+ subsections — matching how Obsidian and most tools scope a section. Replacement content is free to introduce new h2 headings; since the boundary is determined by the original document, the duplication reported here goes away.

    Since section semantics are one of those things people will never fully agree on, the old behavior stays reachable: a new parameter on the replace_section operation (working name replace_subsections: bool = True) lets callers opt back into replacing only the immediate content up to the first subheading. Docstring, edit_note tool docs, and the test locking the old behavior get updated together. PR incoming.

  5. added this to the v0.23 milestone on Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions