docir — design documents Graph

Documents / Issues / issue-9d4db5cd5f29

get --section returns the heading, --replace-section writes it again — and nothing can remove the duplicate

The read/write round trip on one section duplicates its heading, and no edit mode but --replace-body --force can undo it.

issue-9d4db5cd5f29issueresolved#cli#material
View as Markdown◉ View in graph

Class: missing · Severity: material Flow: arch-3e305bc76ff0 · Step: docir get <id> --section → docir update <id> --replace-section

Finding

get --section returns the heading line and the text under it. --replace-section keeps the document's own heading line and writes what it is given under it. So the obvious round trip — read a section, edit it, write it back — writes the heading twice, and nothing validates the replacement text.

The CLI docstring invites exactly that round trip: "--section takes a heading and returns that heading plus the text under it — the same span --replace-section would overwrite." The span is the same for locating; it is off by the heading line for writing, and nothing said so. --append-section has the identical hole in its content argument.

What happens today

REPRODUCED end to end on a scratch store, seeded ## Notes / ## Other:

text
docir get <id> --section Notes
  body -> '## Notes\n\noriginal text\n'

docir update <id> --replace-section Notes --body-file <that text, edited>
  body -> '## Notes\n\n## Notes\n\nedited text\n\n## Other\n\ntail\n'

The duplicate cannot then be removed. _locate_section matches the first heading and replace_section keeps that line by contract; --append-section adds a sibling (a third run gave three ## Notes). Only --replace-body --force — the riskiest edit mode, the one with the divergence guard, the one the agent guide ranks last — can leave the state. This is the shape of issue-d5f68b44b1d9 one level out: the safest body edit reaches a state only the riskiest one can leave.

It gets worse on the second attempt. --replace-section Notes now writes into the first, empty section and the real content stays stranded under the phantom heading:

text
docir update <id> --replace-section Notes --body "clean"
  body -> '## Notes\n\nclean\n\n## Notes\n\nedited text\n\n## Other\n\ntail\n'

The agent asked to replace Notes and the text it meant to replace survives untouched, reported as success.

Impact

Silent body corruption on the round trip the read path exists to enable — context ranks a section, get --section reads it, --replace-section writes it back. Agents are the likely victims: they compose the replacement from the span they just read, where it carries its heading.

docir check sees nothing — a repeated heading is neither malformed frontmatter nor a graph problem. docir lint --deep reports ambiguous-heading (issue-71555a89a73d), but Tier 2 is advisory and after the fact, and it names no way out.

Proposed direction

Two halves, because the second is what issue-d5f68b44b1d9 left out.

Refuse at Tier 0, on both write modes, when the replacement text opens with a heading whose text repeats the one being written under, and let the error say to drop that line. Narrow deliberately: text that merely mentions the heading later, or quotes it in a fence, is legitimate. Stripping the line instead was rejected for the same reason issue-d5f68b44b1d9 rejected it — it guesses at an intent the caller stated.

And add docir update <id> --remove-section "<heading>", so a body that already carries a duplicate has an exit that is not --replace-body --force. Repair was out of scope in issue-d5f68b44b1d9 because one document was affected and check --fix repairs only what needs no guess; that reasoning holds for automatic repair and not for giving the caller a verb. Removal matches like every other section operation — first heading whose text matches — so removing the second of two means running it twice.

Resolution

FIXED, in both halves the filing asked for.

append_section and replace_section now refuse replacement text whose first line is a heading repeating the one being written under, at Tier 0, and the error names the flag and what to pass instead. Narrow on purpose: a section may open with a sub-heading of its own, prose may name the heading further down, and a fence may quote it — scan_headings already tells those apart, and all three are pinned as tests. Stripping the line instead was rejected for the reason issue-d5f68b44b1d9 gave: it guesses at an intent the caller stated.

docir update <id> --remove-section "<heading>" deletes a heading and everything under it, on the CLI and as docir_update(remove_section=) over MCP. It is a body edit mode like the others — at most one per call, resolved against the body as it is on disk, so it composes with an out-of-band change and never needs the divergence guard. It uses the same end boundary as get --section, and carries no #-marker guard: extract_section's reason turned around, a body that already spells ## ## Resolution has to be nameable to be repairable.

Removal resolves a repeated heading to the first, like every other section operation, so taking out the second of two is the same command twice. Addressing the later one would delete a span get --section never showed, which is the divergence this module exists to prevent.

It takes no text, and a --body passed alongside it is refused rather than ignored: --remove-section X --body "..." reads as "delete this text from X" and would delete the whole section, consuming nothing. The check sits in the Dispatcher, beside the one _get makes, because both transports fold body into a mode and neither can be where a wire rule is decided — one check covers the CLI and MCP. It fires only when nothing else would consume the text: naming a writing mode as well is a different mistake with its own error, and answering it with this one would blame the argument that is doing its job.

The get --section docstring no longer claims the read and the write are the same span without qualification — they agree on where the section ends and differ by the heading line, and that one line was the whole defect.

Verified by injection: disabling the round-trip guard fails four tests and leaves the three "this is ordinary content" tests green; disabling the stray-body check fails the CLI and MCP tests for it while the mode-conflict test stays green; dropping remove_section from the MCP payload fails the MCP test, and dropping it from the mode list fails the mutual-exclusion test.

To amend: Re-verify: