Repository navigation
Keep unchanged blocks when a connected app replaces a section #347
Description
Parent
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_sectioncall 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 /contentand Restore still replace every node, as today. -
apps/hocuspocus.server/API.md,docs/mcp/reference.mdandapps/hocuspocus.server/CLAUDE.md§MCP Connector describe the new behaviour.
Blocked by
- Refuse a connected-app rewrite of a section that holds media #329 — it adds the media refusal and the refusal count that opens this gate.
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_sectionparses the Markdown, checksrevagainst a pre-read, then callsdeps.content.applywithmode: '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_documentshows 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
revhashes 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 givesconflict, not lost text. Only keystrokes in flight at the moment of the write are lost. - Public
PATCH /contentaccepts onlyreplaceandappend(document-content/http/schema.ts:24-26).sectionmode exists only on the internal hop (:44), and its one caller isreplace_section. Restore calls the applier inreplacemode (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_sectiononly, beforedeps.content.apply:- Take the live section body from the pre-read that the
revcheck already uses. - Round-trip the whole body:
redactMedia, thenexportMarkdown, thenparseMarkdown. Round-trip the whole body, not one block at a time, because two lists side by side can merge when parsed together. - If the round trip gives a different block count than the live body, fall back to today's full replace.
- Match the common first blocks, then the common last blocks, by key-sorted JSON. Cap the middle run; above the cap, fall back.
- Put the live block's full JSON in place of each matched new block. The applier's
revcheck proves the pre-read still equals the live section. - Run the Refuse a connected-app rewrite of a section that holds media #329 section-media check on the unmatched live blocks only.
- Take the live section body from the pre-read that the
- Applier half, in
sectionmode 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
slugandversion(documentTools.ts:88-91).
Where to start:
replace_sectionandparseFragmentinapps/hocuspocus.server/src/modules/mcp/tools/documentTools.ts.applyContentToDocandlocateSectioninapps/hocuspocus.server/src/modules/document-content/domain/applyContentToDoc.ts.canonicalJsonandsectionAtinapps/hocuspocus.server/src/modules/document-content/domain/sections.ts.redactMediainapps/hocuspocus.server/src/modules/mcp/domain/redactMedia.ts.exportMarkdownindocument-conversion/domain/markdownExport.tsandparseMarkdownindocument-conversion/domain/markdownImport.ts.canonicalJsonis 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 aDocumentClientAuthorrow 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, thenbun run lintandbun run formatfrom the repo root.- Local end to end: run
make dev-local, thenclaude mcp add --transport http docs-plus-local http://localhost:4000/api/mcp. Run/mcpin 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
- The media refusal itself and its text — Refuse a connected-app rewrite of a section that holds media #329.
- A per-block
revto avoidconflictin busy rooms. It is a larger design with its own loss risk. - REST
PATCHand Restore. They keep today's full clone.
Activity
feat(hocuspocus): let AI apps edit at a position, not replace a section
HMarzban commentedon Oct 7, 2026
Superseded by positioned edits in cb1b54f (on
main, deployed 2026-10-06).replace_sectionis gone.edit_blockschanges only the blocks it names and refuses to remove media, so unchanged blocks are kept by design.