Skip to content

Keep unchanged blocks when a connected app replaces a section #347

Description

@HMarzban

Parent

#328. Related: #229

What to build

Today replace_section deletes every block under a heading and inserts new copies. After #329, a connected app is refused on any section that holds media, even for a one-typo fix. This issue keeps each block the connected app did not change. The refusal then applies only when a changed block holds media, and History keeps the authors of the kept blocks.

This is a Later item. Build it only when the maintainer opens its gate on this issue.

Acceptance criteria

  • The maintainer has posted on this issue that the gate is open. The post names the half to build: tool, applier, or both.
  • Tool half: a replace_section call that changes one paragraph in a section with a picture is applied. The picture stays, with its size and alignment.
  • Tool half: a call that changes or drops the block that holds the picture is still refused, with the Refuse a connected-app rewrite of a section that holds media #329 text.
  • Tool half: blocks match one to one, in document order. One new block never keeps more than one live block.
  • Tool half: the common first and last blocks are matched first. A middle run above a fixed size falls back to today's full replace.
  • Applier half: a live block whose new copy is equal stays as the same Yjs item. History Authors names its author, not "Not recorded".
  • REST PATCH /content and Restore still replace every node, as today.
  • apps/hocuspocus.server/API.md, docs/mcp/reference.md and apps/hocuspocus.server/CLAUDE.md §MCP Connector describe the new behaviour.

Blocked by

Agent brief

Type: HITL — the maintainer reads the 30-day count of replace_section:refused-section-media, which #329 adds. The exact count is that field in the mcp:usage:<day> Redis hashes. The admin dashboard page /mcp (30d) shows it inside the replace_section "Refused" column, together with other refusals. The maintainer then posts on this issue which half to build. Proposed gate: 20 refusals in 30 days opens the tool half. One written report of lost text opens the applier half. The maintainer may change these numbers.

Category: refactor

Current behavior:

  • replace_section parses the Markdown, checks rev against a pre-read, then calls deps.content.apply with mode: 'section' (apps/hocuspocus.server/src/modules/mcp/tools/documentTools.ts:283-305).
  • The applier clones every new node, deletes the whole section body, and inserts the clones (apps/hocuspocus.server/src/modules/document-content/domain/applyContentToDoc.ts:60-93). Each new item carries the server's own clientID. So History Authors shows "Not recorded" for the whole section (apps/webapp/src/components/pages/history/components/HistoryAuthorsBody.tsx:185).
  • read_document shows media as a placeholder such as [image] (apps/hocuspocus.server/src/modules/mcp/domain/redactMedia.ts). An agent sees only the placeholder, so a rewrite cannot keep the media.
  • The section rev hashes the heading and body as key-sorted JSON (document-content/domain/sections.ts:38-62). The applier refuses any mismatch (applyContentToDoc.ts:31-32). So a co-author's typing that reached the room gives conflict, not lost text. Only keystrokes in flight at the moment of the write are lost.
  • Public PATCH /content accepts only replace and append (document-content/http/schema.ts:24-26). section mode exists only on the internal hop (:44), and its one caller is replace_section. Restore calls the applier in replace mode (document-versions/infra/versionOps.ts:218).
  • The usage store keeps counts for 35 days, keyed ${tool}:${outcome} (mcp/infra/usageStore.ts:6, :29).

Desired behavior:

  • Tool half, in replace_section only, before deps.content.apply:
    1. Take the live section body from the pre-read that the rev check already uses.
    2. Round-trip the whole body: redactMedia, then exportMarkdown, then parseMarkdown. Round-trip the whole body, not one block at a time, because two lists side by side can merge when parsed together.
    3. If the round trip gives a different block count than the live body, fall back to today's full replace.
    4. Match the common first blocks, then the common last blocks, by key-sorted JSON. Cap the middle run; above the cap, fall back.
    5. Put the live block's full JSON in place of each matched new block. The applier's rev check proves the pre-read still equals the live section.
    6. Run the Refuse a connected-app rewrite of a section that holds media #329 section-media check on the unmatched live blocks only.
  • Applier half, in section mode only: keep a live Yjs item when its key-sorted JSON equals the new block. Delete and insert only the other runs, from the end backwards. Keep the rule "clone before any mutation" (applyContentToDoc.ts:69-73).
  • The reply to the connected app still carries only slug and version (documentTools.ts:88-91).

Where to start:

  • replace_section and parseFragment in apps/hocuspocus.server/src/modules/mcp/tools/documentTools.ts.
  • applyContentToDoc and locateSection in apps/hocuspocus.server/src/modules/document-content/domain/applyContentToDoc.ts.
  • canonicalJson and sectionAt in apps/hocuspocus.server/src/modules/document-content/domain/sections.ts.
  • redactMedia in apps/hocuspocus.server/src/modules/mcp/domain/redactMedia.ts.
  • exportMarkdown in document-conversion/domain/markdownExport.ts and parseMarkdown in document-conversion/domain/markdownImport.ts.
  • canonicalJson is not exported today. Export it; do not copy it.
  • Put the matcher in one pure function in mcp/domain/.

Line numbers are hints as of 2026-09-28; the agent searches by symbol.

Rules that apply:

  • apps/hocuspocus.server/CLAUDE.md §MCP Connector ("A write keeps its heading").
  • apps/webapp/CLAUDE.md §Document Version History (block authorship: never write a DocumentClientAuthor row for a server clientID).
  • AGENTS.md §Test Policy.
  • Doc prose follows .cursor/skills/tech-writer/SKILL.md §Simplified English.

Verify:

  • The matcher is dense branching logic (trim, cap, count fallback), so AGENTS.md §Test Policy case (c) allows one unit test beside apps/hocuspocus.server/src/modules/mcp/__tests__/unit/outline.test.ts. Cover: one changed middle paragraph, a changed media block, a count mismatch, and the cap fallback. Prove it by sabotage: make the matcher return "all matched", and the test must fail.
  • cd apps/hocuspocus.server && bun test src/modules/mcp && bun run typecheck, then bun run lint and bun run format from the repo root.
  • Local end to end: run make dev-local, then claude mcp add --transport http docs-plus-local http://localhost:4000/api/mcp. Run /mcp in Claude Code and sign in as the pad owner. In a pad you own, make a section with two paragraphs and one picture. Ask Claude to fix a typo in the second paragraph. The picture stays in the pad. For the applier half, open History and check that the kept blocks name their author.

Out of scope

Activity

  1. HMarzban commented on Oct 7, 2026

    @HMarzban
    CollaboratorAuthor

    Superseded by positioned edits in cb1b54f (on main, deployed 2026-10-06). replace_section is gone. edit_blocks changes only the blocks it names and refuses to remove media, so unchanged blocks are kept by design.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions