SIGN IN SIGN UP

Markdown patch 2.0 (#301)

* Add markdown-patch 2.0 as a second dependency alongside 1.x

Introduce the 2.0 patch engine as a distinct package so the REST API can
serve both engines side by side during the migration. Because both share the
npm name `markdown-patch`, 2.0 is installed under the alias `markdown-patch-2`.

During development it resolves to the local working copy via `file:../markdown-patch`
(the two repos are siblings under Projects/); at release this will be swapped to
a published npm alias, e.g. `"markdown-patch-2": "npm:markdown-patch@^2.0.0"`.

`markdown-patch` continues to export the 1.x `applyPatch`/`getDocumentMap`
surface; `markdown-patch-2` exports the 2.0 `patch()` engine. No call sites use
the new package yet — this commit is only the plumbing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Route PATCH to the markdown-patch 2.0 engine via MD-Patch-Version: 2

Add an opt-in second engine to the vault PATCH endpoint. When a request carries
`MD-Patch-Version: 2`, the whole instruction is read from a JSON request body (an
`InstructionInput` from markdown-patch 2.0) and applied by the 2.0 `patch()`
engine; the URL supplies only the file. Without the header the request is served
by the existing header-driven 1.x path, byte-for-byte as before, so no existing
client is affected.

The 2.0 algebra is far richer than the 1.x header vocabulary can express
(four scopes including `parent`, a `destination` move carrier, arbitrary-JSON
`value`, `ifMatch`), which is why v2 takes a structured JSON body rather than
extending the header set.

Details:
- `VaultOperations.patchFileSectionV2` reads the file, applies the instruction,
  writes the result, and returns the `PatchResult` (document + warnings).
- The handler validates the instruction discriminants (targetType/operation/
  scope) up front for clean 400s, then maps the engine's typed errors:
  TargetNotFoundError -> 404, PreconditionFailedError (ifMatch) -> 412,
  ContentPreexistsError -> 409, InvalidCellError/other -> 400.
- Advisory warnings (e.g. heading-depth overflow) are JSON-encoded in the
  `MD-Patch-Warnings` response header; the patched document is the body.
- New `InvalidPatchInstruction` (40081) error code for malformed v2 bodies.
- New v2 discriminant guards (isV2Operation/isV2Scope/isV2TargetType) alongside
  the 1.x guards, since 2.0 widens `operation` (adds delete) and `scope` (adds
  parent).

Tests drive the real 2.0 engine end to end through the mock vault, covering the
happy paths (content default, frontmatter `value`, warnings header), the error
mappings, and that a request without the header still routes to 1.x.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Select the 2.0 engine by request shape, not a version header

Replace the MD-Patch-Version header with a presence heuristic, matching how the
markdown-patch-1.0 migration distinguished engines (by which headers a request
carried, not a dedicated version header). Every valid 1.x PATCH requires a
Target-Type header; a 2.0 request carries no Target-Type and puts its whole
instruction in a JSON body. So `_vaultPatch` routes to the 2.0 engine when a
request has no Target-Type header and an object body, and stays on the 1.x path
otherwise — including a malformed 1.x request (no Target-Type, text body), which
still gets the unchanged missing-Target-Type error. No new header surface, and
1.x behavior is preserved byte-for-byte. This applies to the active/ and
periodic/ PATCH endpoints too, since both delegate to `_vaultPatch`.

Also rename the handler and operation off "V2" (`_vaultPatchV2` ->
`_vaultPatchMdp2`, `patchFileSectionV2` -> `patchFileSectionMdp2`): the removed
deprecated API-version-2.0 PATCH handler was itself once named `_vaultPatchV2`,
so "Mdp2" (markdown-patch 2.0) avoids overloading that meaning. Since routing
now guarantees an object body, the handler drops its own non-object check and
takes the parsed instruction directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Make the vault_patch MCP tool 2.0-only

Replace the vault_patch MCP tool's 1.x schema and behavior with the
markdown-patch 2.0 algebra. LLM callers don't need the backward-compatible 1.x
format, so there is a single patch surface rather than two.

The tool now accepts a structured instruction: operation (replace/prepend/
append/delete) applied to a scope (content/marker/markerAndContent/parent) of a
target, with the payload in exactly one carrier chosen by what it is — `content`
(a markdown/text string), `value` (arbitrary JSON, for frontmatter values), or
`destination` (a heading move). Heading targets are addressed as an array of
heading texts (matching the 2.0 REST JSON body), not a '::'-delimited string,
and heading levels inside content are relative to the target. The handler
assembles the instruction from whichever fields were supplied and delegates to
VaultOperations.patchFileSectionMdp2; the engine validates the
operation×scope×targetType combination and the carrier. Advisory warnings are
returned alongside the OK result.

The MCP tool schema must be a flat Zod object (the SDK's tool() signature), so
the discriminated union is expressed as optional fields validated downstream
rather than a z.discriminatedUnion. Also updates the tag-management guidance in
the vault_tags tool description to the 2.0 `value` carrier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Mark the 1.x PATCH format deprecated with a Deprecation header

Every response served by the header-driven 1.x PATCH path now carries
`Deprecation: true; sunset-version="5.0"` (RFC 8594), announcing that the format
is deprecated in favor of the 2.0 JSON instruction body and will be removed in
5.0. The header is set once the request is committed to the 1.x path, so it
appears on both success and error responses, and it is absent from 2.0
responses. This mirrors how the previously-removed 2.x heading format was
sunset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Rewrite the PATCH OpenAPI docs for the 2.0 format

Document the PATCH endpoint as the markdown-patch 2.0 JSON-instruction format
only, and reduce the deprecated 1.x header-driven format to an upgrade notice —
matching how the previously-removed 2.x heading format was handled (left
undocumented with a migration guide).

- New `PatchInstruction` (and `HeadingAddress`) schema components describing the
  operation×scope×target algebra and the three payload carriers
  (content/value/destination).
- `patch.jsonnet` now takes an `application/json` instruction body (no header
  parameters), with worked examples (heading append, table rows, frontmatter,
  tags, move). Documents the 409 (rejectIfContentPreexists) and 412 (ifMatch)
  responses and the `MD-Patch-Warnings` response header.
- `descriptions/patch.md` rewritten around the JSON format, with a
  "Deprecated: the 1.x header-driven format" section carrying the header→field
  upgrade table and the `sunset-version="5.0"` notice.
- MCP tools/call example uses the array heading target.
- Regenerated docs/openapi.yaml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Document the 2.0 PATCH format and 1.x deprecation in the README

Update the quick-start and "Patching notes" examples to the JSON instruction
body (targetType/target-array/operation/scope with content/value/destination
carriers), note relative heading levels, the MD-Patch-Warnings header, and
ifMatch. Add a deprecation callout for the header-driven 1.x format (removal in
5.0, Deprecation header) and clarify that PATCH no longer uses header targeting
while GET/PUT/POST still do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add 2.0 PATCH integration tests; migrate MCP patch integration to 2.0

New src/integration/patch2.test.ts exercises the 2.0 JSON-instruction PATCH
format against a live Obsidian instance: heading content (append/prepend/replace,
default scope, nested array target, marker rename), block content, frontmatter
via the `value` carrier (replace and list merge), delete, the MD-Patch-Warnings
header on heading-depth overflow, ifMatch mismatch -> 412, createTargetIfMissing,
target-not-found -> 404, invalid targetType -> 400, directory -> 405, and that
2.0 responses omit the Deprecation header while header-driven 1.x requests still
work and carry it.

Also migrates the vault_patch cases in mcp.test.ts to the 2.0 tool shape (array
heading target, `value` carrier) and replaces the obsolete JSON-string-parsing
case with a native-JSON-array case and an unresolvable-target error case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Bump version to 5.0.0 for the 2.0 patch/map default

The 2.0 markdown-patch format becomes the default for PATCH, the document map,
and targeted reads, with the 1.x behavior available only on explicit opt-in.
That is a breaking change for existing REST clients, so the next release is a
major bump. package.json is updated here; manifest.json/versions.json and the
lockfile are regenerated by the release ceremony (npm run version) at ship time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Select the PATCH engine by Markdown-Patch-Version header

Replace the request-shape heuristic with an explicit Markdown-Patch-Version
header. The 2.0 JSON-instruction format is now the default: an absent header or
"2" routes to the 2.0 engine; "1" opts back into the deprecated header-driven
format; any other value returns 400 (InvalidPatchVersionHeader, 40082). A 2.0
request with a non-object body returns 400 (InvalidPatchInstruction) with a hint
to send application/json or set Markdown-Patch-Version: 1.

This makes 2.0 the default for all clients (a breaking change for 1.x callers,
hence the major bump) and moves selection off body shape so it can also govern
the document map and targeted reads consistently. The 1.x Deprecation header now
advertises sunset-version="6.0" — the major after this release.

Existing 1.x unit tests opt in via the header; new tests cover the invalid
header value, the non-object-body rejection, explicit "2", and header-forced 1.x.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Default the document map to the 2.0 format with a version token

The document map now returns the markdown-patch 2.0 PublicMap shape by default:
heading addresses as null-padded arrays (pass one straight back as a patch or
read target), bare block ids, frontmatter field names, and a content-hash
`version` token clients send as a patch `ifMatch` precondition — closing the gap
where 2.0's optimistic concurrency had no readable token.

REST honors Markdown-Patch-Version: 1 to get the deprecated `::`-joined shape
(with a Deprecation: sunset-version="6.0" header); an invalid header value is
400. The MCP vault_get_document_map tool is 2.0-only, matching the 2.0-only
vault_patch tool, and its description now explains the array addresses and the
version/ifMatch handshake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Default targeted reads to the 2.0 model

Targeted section reads (REST GET with Target-Type/Target headers, and the MCP
vault_read tool) now resolve through the 2.0 model via the new readTarget helper.
The extracted text is byte-identical to the 1.x extraction for every case, so
read output is unchanged; what changes is the address grammar and the default.

REST honors Markdown-Patch-Version: 1 to keep the deprecated 1.x extraction
(with a Deprecation: sunset-version="6.0" header); the default 2.0 path splits
the Target-Delimiter string into a heading path array internally, so the REST
Target/Target-Delimiter header interface is unchanged. An invalid version header
is 400.

The MCP vault_read tool is 2.0-only: its heading target is now an array (a bare
string is accepted and wrapped), matching vault_patch and vault_get_document_map;
an array target for a block/frontmatter read is rejected. targetDelimiter is
dropped from the tool since heading paths are arrays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Document the 2.0 default, version header, and 6.0 sunset

Update the OpenAPI source, regenerated spec, and README for the 2.0-by-default
switch:

- Add a shared Markdown-Patch-Version header parameter (default 2; 1 opts into
  the deprecated format) to the GET and PATCH operations.
- Rewrite the document-map response schema to the 2.0 shape: a version token,
  heading addresses as arrays, and bare block ids — noting the 1.x ::-joined
  shape is available via Markdown-Patch-Version: 1.
- Move the patch deprecation notice off the old request-shape heuristic and onto
  the header; bump the advertised sunset from 5.0 to 6.0 everywhere.
- Add the 400 InvalidPatchVersionHeader response and refresh the heading-depth
  guidance for array addresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Update integration tests for the 2.0 default and version header

Migrate the live-Obsidian integration suite to the header-driven engine
selection:

- patch.test.ts opts every request into the deprecated format with
  Markdown-Patch-Version: 1 (that suite exercises 1.x on purpose).
- patch2.test.ts: the embedded 1.x check now sends the opt-in header and expects
  sunset-version="6.0"; add cases for a 1.x-style request without the header
  falling through to the 2.0 engine (400) and an invalid version header (400).
- vault.test.ts: the document-map test now asserts the 2.0 shape (array
  addresses + version) and adds a 1.x-map case; targeted reads gain default
  (no Deprecation) and 1.x (Deprecation: 6.0) coverage.
- mcp.test.ts: vault_read nested target is an array; vault_get_document_map
  asserts the 2.0 array/version shape.
- active.test.ts: the /active/ PATCH now uses the 2.0 JSON instruction body.

Full suite green against a live Obsidian instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Remove unrelated linter-warnings plan document

docs/plans/2026-06-11-001-fix-resolve-linter-warnings-plan.md was
accidentally swept into 74fce0e alongside the Markdown-Patch-Version
header work. It documents an unrelated ESLint cleanup effort and had the
side effect of introducing a docs/plans/ directory that exists nowhere
else in the repository's history.

The plan is also still unimplemented on this branch: the require()
imports in vaultOperations.ts, the unhandled .catch(next) promises in
requestHandler.ts, and the .eslintrc parserOptions.project gap are all
untouched. Removing it here rather than deleting the work outright --
the content remains recoverable from 74fce0e and can be reintroduced on
a dedicated branch when the linter cleanup is picked up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Remove local test vault from version control

test_vault/ was accidentally swept into 74fce0e alongside the
Markdown-Patch-Version header work. It is local scratch state, not a
test fixture: nothing in the repository references the path, Welcome.md
is the unmodified Obsidian scaffold, and the plugins/ entry is a symlink
hardcoded to an absolute path under one developer's home directory that
would not resolve for anyone else.

It also carried per-user Obsidian UI state (workspace.json, graph.json,
appearance.json) that churns on every launch and would generate noise in
unrelated diffs. Added test_vault/ to .gitignore so a local vault at
that path stays untracked going forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Present the JSON instruction format as the API, not as a version

The PATCH documentation repeatedly qualified itself as "the 2.0 format",
"the 2.0 shape", or "the markdown-patch 2.0 algebra". That framing asks
readers to hold a version distinction they have no reason to care about:
for anyone meeting this API for the first time, the JSON instruction
format simply is the API, and the qualifier reads as though there were a
choice to make on every request.

Dropped the version qualifiers from the primary descriptions across
patch.md, targeting.md, the jsonnet sources, the PatchInstruction schema,
the MCP tool description, the README, and the non-object-body error
message, and regenerated docs/openapi.yaml.

Retained version references in the two places they carry real
information: the "Deprecated: the 1.x header-driven format" section, with
the migration pointer near the top of patch.md for readers who know they
are on the old format, and the Markdown-Patch-Version header's own
documentation, where the 1-vs-2 distinction is the subject. That header
is now described as something you do not normally need to send, rather
than as a selector between two co-equal formats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Drop legacy header names from the PATCH intro and clarify markerAndContent

The PATCH description opened by telling readers there are no
Operation/Target-* headers to set. For anyone meeting this endpoint for
the first time that is noise: it introduces header names they have no
reason to expect, purely to say they are absent. Removed the clause and
trimmed the same enumeration from the README's deprecation aside, which
now identifies the old format as "header-driven" and links to the
interactive docs for the field-by-field mapping. PATCH's primary prose
now names no legacy header at all; the mapping table in the deprecated
section still spells each one out, where it is the subject.

Left the Target-Type/Target documentation in targeting.md untouched.
Those descriptions attach to GET/PUT/POST, where the headers remain the
live targeting mechanism rather than deprecated surface.

Also reworked the markerAndContent bullet, which described the scope as
"the whole node/subtree" without saying the thing that actually
distinguishes it from content: the heading line is inside the edited
span, so replace rewrites the heading itself. Documented the level
behavior confirmed against the engine -- content headings are rebased to
the target's own level with internal nesting preserved -- and added the
corresponding footgun: a markerAndContent replace whose content has no
heading dissolves the section into a plain paragraph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Correct the documented whitespace behavior

The "Blank lines are not synthesized" section gave advice that does not
work, found by running it against the engine. It told callers to end
content with \n\n for append/replace and start it with \n\n for prepend.
A trailing \n\n on an append at the end of a document is normalized away
entirely, and a leading \n\n on a prepend produces two blank lines rather
than one. The correct separator in every case is a single leading
newline, including for append.

The framing was misleading too. Saying a blank line "is preserved only if
one already existed" implies the API inspects the boundary, which invites
the wrong prediction: prepending into a section whose heading is followed
by a blank line still lands flush against the heading, because that blank
line belongs to the body and is pushed below the inserted text.

Replaced it with the rule the engine implements -- content is spliced
verbatim at one edge of the target's span and the API contributes no
whitespace of its own -- and worked prepend, append, and replace through
one example, with and without the leading newline. Every quoted string is
byte-exact against the engine and is pinned upstream by
markdown-patch's docs.whitespace.test.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Explain renaming a heading without the marker digression

The rename instructions made readers learn about heading markers and
count `#` characters. For PATCH none of that is needed: a marker-scope
replace takes the new text and preserves the level, so there is no depth
to look up. Gave PATCH its own "Renaming a heading" section that says
exactly that, plus the block-id and frontmatter-key equivalents.

Including `#` characters is not merely unnecessary there, it is wrong.
They are not stripped, so "## New Name" renames the heading to the
literal `## New Name`. Since the deprecated header-driven format
*required* those `#`s, anyone migrating will carry them over by habit and
silently corrupt the heading, so both documents now warn about it
explicitly.

targeting.md needed more than trimming. It attaches to the GET/PUT/POST
descriptions, which still run the 1.x engine where the `#`s are genuinely
required -- but it illustrated the rule with a `curl -X PATCH` example,
demonstrating the deprecated format under the endpoints it does not
describe, and teaching PATCH readers the reverse of PATCH's actual rule.
Switched the example to PUT, which is the verb that maps to a targeted
replace, led with a pointer to PATCH as the easier path, and stated
plainly that the two rules are opposites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Describe only the PATCH way to rename a heading

The rename guidance presented two methods and asked the reader to choose,
which is a choice they have no reason to make: PATCH renames a heading
from just its new text, and the header-driven alternative only adds
`#`-counting and a depth lookup. Dropped the "prefer PATCH" framing along
with the worked header example, the depth-inference explanation, and the
"useful for renaming" aside on the marker bullet. targeting.md now points
at PATCH in one line, and patch.md carries the whole explanation.

The header-driven scope rules themselves are kept, condensed to a
sentence. They still govern PUT and POST, which route through the 1.x
engine, so removing them would leave that behavior undocumented rather
than merely unmentioned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Remove a dangling comparison left by the rename cleanup

Condensing the rename guidance took out the side-by-side framing but left
the note that closed it, which still opened "These two rules are exact
opposites" with nothing to compare against. The preceding paragraph had
the same problem in miniature, leading with "Here the marker scope..."
where "here" had been contrasting with PATCH.

Dropped the note and the dangling qualifier. The `#` warning it carried
is not lost: patch.md states it at the point of use, where someone
writing a rename instruction will actually meet it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Switch the document map to a nested heading tree

The document map's `headings` moves from a flat list of null-padded path
arrays to a nested `HeadingTree` object: each heading's text maps to an
object of its child headings, and a leaf maps to `{}`. Nesting carries no
heading level, so a skipped source level leaves no hole, and a repeated
sibling appears once at its first occurrence in document order.

Adds the `HeadingTree` OpenAPI schema and points the map's `headings`
field at it, updates the MCP `vault_get_document_map`/`vault_read`/
`vault_patch` heading-address types (dropping the `null` skipped-level
element) and their descriptions, and updates unit and integration tests
to the nested shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Route path-element targeting through the 2.0 engine; deprecate header targeting

Targeted GET/PUT/POST previously ran the 1.x markdown-patch engine for both
URL path-element targeting (`.../heading/A/B`) and the `Target-Type`/`Target`
header form, carrying every 1.x quirk (manual `#` counting, heuristic
whitespace, `::` delimiter). Only PATCH had moved to the 2.0 engine.

This makes the version header the single switch:

- Default (2.0): a sub-part is addressed with URL path elements, routed through
  the 2.0 engine, so heading levels are normalized and the engine owns boundary
  whitespace. Headings are addressed array-natively from the URL segments, so a
  heading containing `::` needs no escaping (resolvePathAndTarget now preserves
  the segments as an array alongside the `::`-joined string).
- Markdown-Patch-Version: 1: the deprecated header-based targeting is processed
  by the 1.x engine, and every 1.x response carries the
  `Deprecation: true; sunset-version="6.0"` advisory (now on PUT/POST too).
- Header targeting without that header is rejected with the new
  `400 HeaderTargetingRequiresVersion1` rather than silently operating on the
  whole file — preventing a header-targeted write from clobbering the note.

`_vaultPatchTargeted` gains a `source` ("path" | "header") discriminant and a
shared `_respondMdp2` responder (extracted from the JSON-body PATCH path). The
same treatment is applied uniformly across the vault, active-file, and periodic
PUT/POST endpoints and the targeted GET read.

Docs stop documenting header-based targeting (removed from the GET/PUT/POST
parameter lists and rewritten around URL path elements in targeting.md and the
README); the header form survives only as a deprecation note. PUT/POST now
document the 2.0 `409` (Reject-If-Content-Preexists) and the MD-Patch-Warnings
header. Unit tests cover the new routing, gating, and level normalization;
integration tests are updated to the new behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Derive the MCP tool and OpenAPI docs from the published patch schema

The instruction algebra was written out three times: once as markdown-
patch-2's types, once as this project's MCP `vault_patch` tool input, and
once as the OpenAPI `PatchInstruction` component. The two copies here
were hand-maintained, drifted from the engine, and enforced none of its
cross-field rules -- the MCP `target` still allowed the null-padded array
elements the engine no longer uses, and the OpenAPI table-row example put
a `value` on a block target, which the 2.0 engine ignores entirely.

Derive both from markdown-patch-2's InstructionInputSchema instead:

  - vault_patch spreads InstructionInputObjectSchema.shape for its
    instruction fields, so the tool input is the schema's field set with
    its descriptions, not a restatement.
  - scripts/gen-patch-schema.mjs runs the same schema through
    zod-to-json-schema to produce docs/src/lib/patchInstruction.schema.json,
    which openapi.jsonnet imports as the PatchInstruction component.
    build-docs runs the generator before jsonnet, and the generated file
    is committed alongside openapi.yaml so the compiled spec is
    reproducible. A small post-process rewrites how the OpenAPI-3 target
    renders a null union branch (`enum: ["null"]`) into `nullable: true`.
  - Replaced the stale block-table-row example with a block content
    append, which is what the 2.0 engine actually does.

The engine now rejects a malformed instruction with a typed
InvalidInstructionError; map it to the same InvalidPatchInstruction
(40081) response as InvalidCellError, since both mean the caller sent an
instruction the algebra does not accept. Added unit and integration
coverage for the new cases: a write missing its carrier, a frontmatter
value write carrying `content`, and an operation×scope outside the
algebra.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Describe the document map's repeated-heading behavior accurately

The MCP tool description and the OpenAPI HeadingTree schema both told
consumers that later same-name siblings and their subtrees are omitted,
and that reaching one meant targeting the next-higher heading or the
whole document. That was never quite true and is now wrong outright:
markdown-patch merges a repeated heading's children into the single key,
so those descendants are listed and directly addressable.

Restate both to describe what actually holds — a repeated name appears
once but keeps its children, only whole-path collisions are a single
address, and the tree lists exactly the headings you can target — with a
worked example, since the distinction between sharing a name and sharing
a path is the part that misleads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Add a failing test for non-ASCII text in MD-Patch-Warnings

Warning messages embed heading text verbatim and are JSON-stringified
into the MD-Patch-Warnings response header without escaping. Node
rejects non-Latin1 characters in header values, so a warning about a
heading containing an emoji or accented letter throws inside
_respondMdp2's res.setHeader call — after the file has already been
written. The exception is caught by the same handler's generic catch
block and reported to the client as 400 PatchFailed, masking a patch
that actually succeeded.

This test captures that failure so the next commit's fix can be
verified against it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Percent-encode MD-Patch-Warnings so non-ASCII text can't crash the response

A warning message embeds document text verbatim (e.g. a heading rebased
past level 6 gets named in the message), and Node rejects header values
outside Latin1. A warning about a heading containing an emoji or
accented letter threw inside res.setHeader, after the patch had already
been written — the exception was caught by _respondMdp2's generic catch
and reported to the client as 400 PatchFailed for a write that actually
succeeded.

Percent-encode the JSON-stringified warnings before setting the header,
matching the convention already used for the Target/Destination request
headers (decodeURIComponent on read). Clients now need
decodeURIComponent before JSON.parse; updated the OpenAPI header
descriptions, patch.md, and README accordingly, and regenerated
docs/openapi.yaml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Rename MD-Patch-Warnings to Markdown-Patch-Warnings for consistency

Markdown-Patch-Version and MD-Patch-Warnings were the two custom
headers introduced by the 2.0 engine, spelled with different prefixes
for the same feature family. Since 2.0 hasn't shipped yet, this is the
cheapest point to fix the inconsistency — standardize on the fuller
Markdown-Patch- prefix, matching the header customers already need to
learn for opting into the deprecated 1.x format.

Also fixes a decodeURIComponent gap this surfaced: the integration
test reading this header never accounted for the percent-encoding
added in the prior commit, since integration tests don't run without
a live Obsidian instance and so nothing caught it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add failing tests for stray 1.x scope headers on v2 path-targeted writes

Target-Scope, Target-Delimiter, and Trim-Target-Whitespace only mean
something for header-based (1.x) targeting, but a v2 path-targeted
PUT/POST currently accepts and silently ignores them rather than
rejecting them like Target-Type/Target already are. Silently ignoring
Target-Scope is a data-loss hazard for an un-upgraded 1.x client: a
Target-Scope: marker heading rename would instead replace the whole
section body.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Reject stray Target-Scope/Target-Delimiter/Trim-Target-Whitespace on v2 path-targeted writes

_vaultPatchTargeted already rejected Target-Type/Target headers (source
"header") unless Markdown-Patch-Version: 1 was sent, but the remaining
1.x-only headers (Target-Scope, Target-Delimiter, Trim-Target-Whitespace)
were silently dropped on a v2 path-targeted PUT/POST rather than
rejected. An un-upgraded 1.x client sending Target-Scope: marker to
rename a heading would have its whole section body replaced instead.
Reject these headers the same way, matching what the OpenAPI docs
already promised (targeting.params.jsonnet, targeting.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add failing tests for path-targeted table-row writes on a block target

A path-targeted PUT/POST against a block target with a JSON array body
currently gets String()-coerced (e.g. [["Chicago","16"]] becomes the
literal text "Chicago,16") instead of being routed to markdown-patch's
new table-row `value` carrier. These tests pin the correct behavior
ahead of wiring it up: a 2-D array body edits table rows, a malformed
row (wrong column count) or a non-array JSON body is rejected with a
400 rather than silently written as garbage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Route JSON block-target writes to markdown-patch's table-row carrier

A path-targeted PUT/POST with a JSON body against a block target was
String()-coercing structured payloads into garbage ("a,b",
"[object Object]") instead of writing table rows, since
markdown-patch's engine had no such capability until this branch's
prior commit. Now a non-string body on a block target rides the
engine's `value` carrier instead of `content`; malformed shapes
(wrong column count, non-table target, non-array body) surface as the
engine's own typed errors via the existing 400 mapping in
_respondMdp2 — no new REST-layer validation needed. Heading targets
keep their existing String() fallback unchanged; that's a separate,
still-open half of the original finding.

The JSON-instruction PATCH endpoint (_vaultPatchMdp2/_respondMdp2)
needed no changes — it already forwards the parsed body verbatim, so
`value` on a block target was already reaching the engine.

Also: vault_patch's tool description and the patch.md/openapi docs
now describe the capability accurately (regenerated openapi.yaml via
npm run build-docs), and integration/unit coverage exercises it
end-to-end against a live Obsidian instance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add failing tests for frontmatter parse and key-collision error mapping

Malformed frontmatter YAML currently 500s on the document map GET (both the
2.0 and deprecated 1.x shapes) and the path-targeted GET, and returns a
generic PatchFailed (40080) rather than InvalidFrontmatter (40005) on PATCH,
now that markdown-patch's buildModel raises a typed FrontmatterParseError
instead of letting a raw YAMLParseError escape.

A frontmatter key collision (renaming onto an existing key, or inserting an
entry whose key is already present) now raises FrontmatterKeyCollisionError
from the engine but isn't yet mapped to a REST response, so it falls through
to the generic 400 PatchFailed instead of a 409.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Map frontmatter parse and key-collision errors to 400/409 responses

Malformed frontmatter YAML now maps to the existing InvalidFrontmatter
(40005) error code -- matching 1.x semantics -- instead of an uncaught
YAMLParseError surfacing as a bare 500, across all three places the 2.0
engine can hit it: the document map GET (both the 2.0 and deprecated 1.x
shapes), the path-targeted GET, and PATCH writes.

FrontmatterKeyCollisionError (a new markdown-patch error for a frontmatter
rename or insert that would collide with an existing key) is mapped to 409,
alongside the existing ContentPreexistsError handling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add integration coverage for frontmatter parse and key-collision errors

Covers the document map GET (2.0 and deprecated 1.x shapes), a
path-targeted GET, and PATCH writes against a live Obsidian instance:
malformed frontmatter YAML now returns 400 (InvalidFrontmatter) instead of a
500, and a frontmatter rename or insert that collides with an existing key
returns 409 instead of silently dropping data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add a failing test for strict heading targets on vault_read

vault_read silently wraps a bare-string heading target into a
single-element array, but vault_patch (validated against markdown-patch's
shared schema) rejects the same shape outright. An agent that learns the
lenient form from vault_read trips on vault_patch for no reason related to
read vs. write semantics.

* Reject a bare string heading target on vault_read

vault_read used to wrap a bare-string heading target into a
single-element array, but vault_patch (validated against markdown-patch's
shared schema) has always rejected the same shape. Match vault_patch's
strictness instead of adding the leniency to vault_patch, since the
array-of-path-segments form is the one both the document map and every
other heading-targeting surface already use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update MCP integration coverage for strict heading targets

Fixes the one call site that relied on vault_read's now-removed
bare-string coercion, sharpens the neighboring not-found test to use a
real array (so it fails for the right reason), and adds explicit
coverage that a bare string is now rejected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add tests for duplicate-heading marker addressing at the REST/MCP layer

markdown-patch-2 now gives each duplicate sibling heading its own
disambiguated address; these tests cover the layers on top of it:

- requestHandler: the 2.0 document-map GET response lists distinct keys
  for duplicate siblings; a path-targeted GET/PUT using a disambiguated
  key reaches (and only writes) the correct occurrence; a heading whose
  raw text collides with the reserved marker sequence currently returns
  500 instead of a clean 400 (40080) — the one genuinely failing case,
  fixed next.
- mcpHandler: vault_get_document_map, vault_read, and vault_patch all
  pass the marker codepoints through target segments/map keys unchanged
  (already correct, since these layers are thin passthroughs — kept as
  regression coverage).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Map ReservedDuplicateMarkerError to a clean 400 instead of a 500

markdown-patch-2's new collision guard throws when a raw heading text
already ends with the sequence reserved for disambiguating duplicate
sibling headings. Both GET paths that parse the document (the 2.0
document-map response and path-targeted section reads) previously
re-threw any unmapped error, surfacing this as an unhandled 500. Map it
to the existing PatchFailed (40080) code with the engine's message,
matching how the PATCH path already treats any other unmapped
EngineError.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update MCP tool descriptions for duplicate-heading marker addressing

vault_get_document_map's description described the old behavior, where
a repeated sibling heading collapsed onto one map key with its
descendants merged in. Now every occurrence gets its own key, so this
rewrites the explanation and adds the same note to vault_read's target
field: copy a marker-suffixed key verbatim from the map response, never
retype or reconstruct one. vault_patch's target field description comes
from markdown-patch-2's shared schema and was already updated there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Document duplicate-heading marker addressing in the REST/OpenAPI docs

targeting.md and patch.md's "Identifying patch targets" section both get
a short note: only the first occurrence of a duplicate sibling heading
is addressable by its plain text, and each later occurrence's key
carries a non-printable marker suffix that must be copied verbatim from
the document map rather than typed by hand.

Regenerating also picked up the target field's already-updated
description from markdown-patch's schema, plus unrelated pre-existing
drift in the content field's description that hadn't been synced since
its last change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add a live-Obsidian round trip for duplicate-heading marker addressing

Covers the residual risk unit tests can't: that the reserved
Supplementary PUA-A codepoints actually survive the real MCP JSON
transport intact. Against a fixture with three sibling "# Notes"
headings, vault_get_document_map returns three distinct keys; each
round-trips through vault_read to its own section body; and vault_patch
on the third occurrence's key edits only that section, verified via a
follow-up whole-file vault_read.

Verified against live Obsidian (39 passed, 2 skipped, unrelated).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add tests for duplicate-block-id marker addressing at the REST/MCP layer

Mirrors the duplicate-heading coverage: the 2.0 document-map GET
response lists a distinct entry per block-id occurrence; a
path-targeted GET/PUT using a disambiguated block id reaches (and only
writes) the correct occurrence; mcpHandler's vault_get_document_map,
vault_read, and vault_patch all pass the marker codepoints through
block targets unchanged.

The PUT test caught a real bug one layer down: markdown-patch's schema
validation independently enforced [A-Za-z0-9_-]+ on a block target,
rejecting the disambiguated form — fixed there in commit 5ea9d05.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Document duplicate-block-id marker addressing in MCP tool descriptions

vault_get_document_map and vault_read's target field now explain that a
duplicate block id gets the same disambiguation treatment as a
duplicate heading. vault_patch's target field description comes from
markdown-patch's shared schema and was already updated there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Document duplicate-block-id marker addressing in the REST/OpenAPI docs

targeting.md and patch.md's "Identifying patch targets" section both
now mention duplicate block reference IDs alongside duplicate sibling
headings: only the first occurrence is addressable by its plain id,
and each later occurrence's key carries a non-printable marker suffix
that must be copied verbatim rather than typed by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add a live-Obsidian round trip for duplicate block-id marker addressing

Mirrors the duplicate-heading integration coverage. Against a fixture
with three blocks sharing "^dup", vault_get_document_map returns three
distinct entries; each round-trips through vault_read to its own block
content; and vault_patch on the third occurrence's id edits only that
block, verified via a follow-up whole-file vault_read.

Verified against live Obsidian (41 passed, 2 skipped, unrelated).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add failing tests for a raw-content PATCH mode

The markdown-patch 2.0 PATCH format carries the whole instruction as a JSON
body, which forces templating-oriented clients (Shortcuts, Tasker, curl
templates) to JSON-string-escape templated markdown — something many of them
cannot do reliably. These tests specify a raw-content mode in which the
instruction's fields travel outside the body (URL path segments like
/vault/note.md/heading/A/B, or Target-Type/Target headers with an explicit
Markdown-Patch-Version: 2 opt-in) and the body is the raw payload: text/* maps
to `content`, application/json to `value`, an empty body plus a Destination
header to `destination`, and delete carries nothing.

Also specified: a dedicated application/vnd.olrapi.patch-instruction+json
content type explicitly declaring an instruction body, 422 conflicts between
the three targeting signals, type-dependent Target header encoding (heading =
percent-encoded JSON array; block/frontmatter = plain percent-encoded string),
standard If-Match (with optional ETag quotes) for optimistic concurrency, and
loud rejection (new error code 40084) of header targeting without an explicit
version — preserving the no-silent-reinterpretation guarantee for un-upgraded
1.x clients.

All but three of these tests fail until the implementation lands in the next
commit; the three that pass today do so via coincidentally-matching error
codes on the current code paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add a raw-content mode to PATCH

A markdown-patch 2.0 PATCH previously accepted only a JSON instruction body,
which forces templating-oriented clients (Shortcuts, Tasker, curl templates)
to JSON-string-escape templated markdown into the instruction's `content`
field. Raw-content mode moves the instruction's fields out of the body — the
target rides in URL path elements (/vault/note.md/heading/A/B, reusing the
same resolution GET/PUT/POST already use) or in Target-Type/Target headers,
the remaining fields in Operation/Target-Scope/Destination/If-Match/
Create-Target-If-Missing/Reject-If-Content-Preexists headers — and the body is
the raw payload: text/* is the `content` carrier, application/json the `value`
carrier, and an empty body carries nothing (a delete, or a move via the
Destination header). The assembled instruction funnels through the same
engine and error mapping as the JSON-instruction mode.

Decisions worth recording:

- Header-based targeting requires an *explicit* Markdown-Patch-Version: 2.
  Absent-version requests with Target headers fail loudly with the new 40084
  error rather than silently reinterpreting an un-upgraded 1.x client's
  headers under 2.0 semantics (a 1.x block/frontmatter target is a valid 2.0
  string target, so parse failures alone would not catch every case).
  URL-element targeting needs no opt-in — 1.x PATCH never had it.
- Target header encoding is type-dependent, mirroring the instruction's
  target shapes: heading Targets are percent-encoded JSON arrays (or null for
  the document root); block/frontmatter Targets are plain percent-encoded
  strings, as in 1.x.
- A new application/vnd.olrapi.patch-instruction+json content type explicitly
  declares an instruction body. The three targeting signals (URL elements,
  Target headers, instruction content type) are mutually exclusive; supplying
  more than one returns 422 ConflictingTargetSpecification.
- An empty body maps to no carrier at all, so a replace with an
  accidentally-empty template fails as a missing carrier instead of clearing
  the section; clearing content deliberately requires the instruction body.
- If-Match accepts both a bare engine version token and an RFC 9110
  quoted-ETag form (one pair of surrounding quotes is stripped).
- Markdown-Patch-Version: 1 plus a URL-element target is an explicit 400:
  URL targeting is purely a 2.0 feature, and before this change that path
  simply 404'd as an unresolvable file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add failing tests for URL-targeted PATCH on active and periodic notes

A URL suffix on /active/* or /periodic/* PATCH requests was previously
dropped on the floor — the whole file was patched as though the suffix were
absent. These tests specify that such a suffix now resolves to a sub-target
and routes into raw-content mode, mirroring how PUT and POST already treat
their suffixes, including the Content-Location advertisement and the 422
conflict when Target headers are also supplied. The no-suffix instruction-body
flow keeps working unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Support URL-targeted raw-content PATCH on active and periodic notes

A URL suffix on /active/* and /periodic/* PATCH requests now resolves to a
sub-target and routes into raw-content mode, mirroring the suffix handling
PUT and POST already perform on these surfaces (including the
Content-Location advertisement). Previously the suffix was silently ignored
and the whole file was patched; an unresolvable suffix still falls through to
the whole-file instruction-body flow. Conflict handling (a suffix plus Target
headers is a 422) lives in _vaultPatch, shared with the /vault/* surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document PATCH raw-content mode in the REST/OpenAPI docs

Covers the new raw-content mode across every documentation layer: a new
section in the PATCH description (motivation, the two target transports and
their encodings, the header set, the body-carrier table, conflict rules, and
curl examples), new OpenAPI header parameters (Target-Type/Target with the
type-dependent encoding, Operation, Target-Scope with all four scopes,
Destination, If-Match, and the create/reject flags), the additional
text/markdown and application/vnd.olrapi.patch-instruction+json request body
content types, the 422 conflict response, and the explicit-version rule in
the Markdown-Patch-Version description. The targeting and deprecation notes
are amended where they previously claimed Target headers were exclusively the
1.x form, and the README gains a raw-content-mode subsection. Includes the
regenerated docs/openapi.yaml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add integration coverage for PATCH raw-content mode

A new patch2raw.test.ts exercises raw-content mode against a live Obsidian
instance: URL-segment append/replace/delete, table-row and frontmatter value
writes from JSON bodies, marker renames and Destination moves from headers,
header targeting under an explicit Markdown-Patch-Version: 2 (and the 40084
rejection without it), an If-Match round trip through the document map's
version token, the 422 conflict cases, the explicit instruction content type,
and the empty-body-is-no-carrier rule. The active and periodic suites gain a
PATCH URL-suffix case each, mirroring their PUT/POST coverage.

One existing patch2.test.ts assertion is updated to the new contract: a
1.x-style header request without the version opt-in now fails with the
explicit-version guidance error (40084) instead of falling through to the
non-object-body rejection (40081).

Full unit and integration suites run green against live Obsidian.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add failing tests for a JSON body on a path-targeted heading write

A path-targeted PUT/POST routes a non-string request body to the 2.0
engine's `value` carrier for block targets, but heading targets fall
through to a `String(req.body ?? "")` coercion. That writes "[object
Object]" (a JSON object) or "x,y" (a JSON array) into the note and
reports 200 — silent corruption, and the comma-joined form is the more
dangerous of the two because it looks like plausible content.

These tests assert the request is rejected and nothing is written,
alongside a regression guard that a JSON body on a frontmatter target
still writes its value through the `value` carrier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Route a structured request body to the value carrier on every target

A path-targeted PUT/POST chose the 2.0 engine's payload carrier per
target type, and only `block` mapped a non-string body to `value`. A
heading target fell through to `String(req.body ?? "")`, which spliced
"[object Object]" or "x,y" into the note and returned 200.

The carrier now follows what the body is rather than what the target is:
a string body is literal text (`content`), a parsed JSON body is
structured data (`value`), and the engine's algebra decides whether that
carrier is legal for the target. Blocks keep taking table rows and
frontmatter keeps taking typed values; a heading, whose content cell
holds markdown text only, now gets a 400 InvalidPatchInstruction naming
the wrong carrier instead of silent corruption.

Frontmatter stays special-cased to `value` regardless of the body's
runtime type: a text/markdown body there is the plain string to store,
not markdown to splice, and that has always worked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add failing tests for error messages that blame the wrong input

Three cases report a problem against an input the caller never supplied:

- A malformed field in a JSON instruction body is reported with the
  4005x codes, whose messages read "The 'Target-Type'/'Operation'/
  'Target-Scope' header you provided was invalid" — there are no headers
  in a JSON body.
- An unknown target type in a URL path element is likewise blamed on the
  'Target-Type' header.
- An invalid Target-Scope in raw-content mode returns two contradictory
  lists of valid values in one message, because getResponseMessage
  prepends the canned text to the call site's custom text and the canned
  text enumerates the 1.x scopes while the custom text adds 'parent'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Report instruction errors against the input the caller supplied

Three error surfaces named an input the caller never sent.

A malformed field in a JSON instruction body was validated by hand
against the 4005x codes, whose messages describe request headers. That
validation now runs the engine's own InstructionInputSchema and reports
InvalidPatchInstruction with the schema's field-path messages, so the
error points at `targetType`/`operation`/`scope` as written. It also
covers the whole instruction rather than three discriminants, and still
runs before the vault is read. Fields that arrive from headers in
raw-content mode are validated by that caller, which keeps the
header-flavored wording appropriate there.

The canned InvalidTargetTypeHeader and InvalidTargetScopeHeader messages
no longer assume a header, since both values can also arrive as URL path
elements; the targeted-read path now says which of the two it read. The
scope message also dropped its enumeration of valid values: the two
patch formats accept different scopes, and because getResponseMessage
prepends the canned text to a call site's custom text, the 2.0 call site
restating the list produced a response carrying both the 1.x list and
the 2.0 list. Each call site now supplies the list that applies to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Surface table cell content errors as a 400 and document the rule

markdown-patch now escapes a `|` in a table cell and rejects a cell
containing a line break, so the REST layer maps the engine's new
InvalidCellContentError to InvalidPatchInstruction rather than letting
it reach the generic catch-all. Adds REST-level coverage that a piped
cell survives a real round trip through the API and that a cell with a
line break is refused without writing the file, plus the matching note
in the PATCH docs and the regenerated spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add failing tests for literal slashes in a URL-targeted heading

The path handling decodes the whole URL remainder in one pass and only
then splits on `/`, so a `%2F` inside a heading name is indistinguishable
from a path separator by the time splitting happens. A heading literally
named "A/B" is therefore unreachable by URL targeting, though the docs
say percent-encoding covers it — verified: GET/PUT/PATCH of
`.../heading/A%2FB` all miss the section.

The same single-decode makes `folder%2Fnote.md` resolve identically to
`folder/note.md`, an accidental equivalence a real file name can't
justify (names can't contain `/`). These tests pin the intended
behavior: a slash-bearing segment addresses one heading, and the whole
file path as a single encoded segment no longer resolves. Coverage spans
the vault routes and the active/periodic suffix, whose wildcard capture
Express decodes before the handler sees it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Decode URL path segments individually so a heading can contain a slash

The vault path was decoded in one pass and only then split on `/`, so a
`%2F` inside a segment was indistinguishable from a separator by the time
splitting happened. A heading literally named "A/B" was therefore
unreachable by URL targeting even though the docs promised percent-
encoding covered it, and `folder%2Fnote.md` resolved identically to
`folder/note.md` — an accidental equivalence a real name can't justify.

The path is now split on the request's raw slashes and each segment
decoded on its own, so an encoded slash stays literal inside the one
segment it belongs to. `resolvePathAndTarget` takes those pre-split
segments, and because a file or folder name can never contain a slash, a
segment that decodes to one is only ever a target address, never a file
component: that resolves "A/B" as a heading and makes the whole file
path as a single encoded segment (`folder%2Fnote.md`) stop resolving.

The traversal guard still resolves the decoded path against the synthetic
vault root, which catches an encoded `..%2F..%2F…` (one segment after
decoding) exactly as before. The active/periodic suffix is recovered from
the raw request path rather than Express's already-decoded wildcard
capture, which had collapsed `%2F` to a boundary before the handler ran;
the static prefix length comes from the matched route pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add a Migrating from 1.x to 2.x guide to the interactive docs

The markdown-patch 2.0 changes deserve a standalone migration document
rather than a note buried in an operation description. This adds the
guide as an x-topics entry (Bump.sh's OpenAPI extension for standalone
topic pages), which our customized Stoplight Elements bundle renders as
a sidebar article between Overview and Endpoints; other OpenAPI tooling
ignores the extension.

The guide covers the loud-failure behavior for un-versioned legacy
requests and the Markdown-Patch-Version: 1 stopgap, the header-to-JSON
field mapping for PATCH, raw-content mode as the low-effort migration
for templating clients, URL path-element targeting, the nested document
map with its version token, behavior changes (verbatim whitespace,
relative heading levels, the Markdown-Patch-Warnings rename,
active/periodic URL targets now honored, new 400/409/412 mappings),
the MCP tool changes, and the 5.0-default/6.0-removal timeline.

Note: the article only renders on the public docs site once the
rebuilt Elements fork bundle (with x-topics support) is published to
the S3 bucket backing the docs page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qc34zWTSCC4fjugVj5GCF1

* Document the library-owned whitespace contract from markdown-patch 20c0908

The markdown-patch 2.0 engine now owns whitespace for heading writes
(markdown-patch commit 20c0908, an intentional reversal of its commit
4f84d89): caller content is reduced to trimmed, canonical form and the
engine supplies the blank-line separator wherever spliced content faces
body text, so a naive append/prepend always lands as its own block and
can never merge into an existing paragraph. Leading/trailing newlines
in content are now meaningless rather than a layout channel.

This updates every layer of this repo that described the old "spliced
verbatim / a leading \n buys the blank line" contract:

- README: the PATCH whitespace note now describes the library-owned
  contract, and raw-content mode no longer claims a verbatim splice.
- docs/src/lib/descriptions/patch.md: the "Whitespace is spliced
  verbatim" section is replaced with "Whitespace is library-owned",
  quoting the new worked examples pinned by the engine's test suite.
- docs/src/lib/descriptions/migration-2.0.md: the migration guide's
  behavior-change bullet now describes the new contract, including
  that a content-scope append can no longer continue a list or
  paragraph (inline edits go through a ^id block target).
- docs/openapi.yaml and patchInstruction.schema.json: regenerated;
  the PatchInstruction content-field description (sourced from the
  markdown-patch Zod schema, which the MCP vault_patch tool also
  inherits) now states the whitespace contract.
- src/requestHandler.test.ts: expectations updated for the blank line
  the engine now supplies at append joints.

Unit tests pass (359/359). Integration tests were not run: the live
Obsidian instance currently has an older plugin release loaded, and
this branch's build would need to be installed first; the integration
assertions touching PATCH are containment-based and unaffected by the
separator change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Expose the within field through the JSON PATCH mode and OpenAPI docs

The markdown-patch 2.0 engine gained a within field: a scalar index
refining a heading target to one of the section's direct-body top-level
blocks (negative counting from the end), whose content-scope edits are
literal splices — restoring the ability to continue an existing list or
paragraph without a ^id block reference. Because the JSON instruction
mode validates the whole request body against the library's own schema,
no handler changes are needed; this updates the file: dependency
lockfile, regenerates the PatchInstruction component and openapi.yaml
from the library's Zod schema, documents within in the PATCH operation
prose (algebra entry, worked continue-a-list example, ifMatch pairing
guidance), and adds integration coverage for the literal splice,
negative indices, sibling inserts, deletes, and the rejected combos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pass within through the MCP vault_patch tool

The tool's parameter shape already picked the field up automatically
via the spread of markdown-patch-2's published schema, but the callback
explicitly destructures and reassembles each instruction field, so an
unlisted field was silently dropped — which for within would have meant
editing the whole section instead of the addressed block. Adds within
to the destructure, the parameter type, and the assembly, and extends
the tool description with the continue-a-block usage (literal splice,
negative indices, pairing with ifMatch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add a Within header to PATCH raw-content mode

Raw-content mode carries the instruction's fields in headers so
templating clients can splice unescaped markdown bodies; the new
markdown-patch within field gets the same treatment as a plain-integer
Within header (e.g. `Within: -1`). Validation is strict — parseInt
would accept trailing garbage like `1x` and silently land the edit on
the wrong block, so anything but `-?digits` is rejected with the new
40023 InvalidWithinHeader error before the instruction is assembled;
the within×scope matrix itself stays delegated to the library schema
via the shared validation path. Documents the header in the OpenAPI
parameter list and the raw-mode prose, with a curl example extending a
section's last list in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document the within field in the README

The README's patching section never mentioned within, and its
raw-content-mode header list omitted the Within header — the one gap
left against the keep-in-sync checklist, since the OpenAPI docs and
MCP tool descriptions already cover both. Adds a short
continue-an-existing-block subsection with a curl example mirroring
the interactive docs' recipe, and slots Within into the header list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Correct getDocumentMapV2Object's stale duplicate-heading note

The docstring still described the map as keeping only a repeated
sibling's first occurrence — true of an early projection, but since
markdown-patch's duplicate-marker addressing every occurrence gets its
own disambiguated key (and duplicate block ids are handled the same
way). Comment-only change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Accept Target-Scope on path-targeted GET reads

PATCH can act on marker and markerAndContent scopes, but the read side
only ever returned content — so a caller wanting to edit a heading's
subtree at markerAndContent had to assemble the payload by hand,
re-counting the '#' levels the engine normalizes away. A path-targeted
GET now accepts a Target-Scope header (content, the default; marker;
markerAndContent), mirroring the write scopes with the library's
round-trip guarantee: what a scope returns is exactly what a replace at
that scope consumes. 'parent' carries no readable value and is rejected
with InvalidTargetScopeHeader; the deprecated 1.x header-targeted read
path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add a scope parameter to the MCP vault_read tool

Mirrors the Target-Scope header the REST read path just gained:
vault_read can now fetch a node's label (marker) or its whole subtree
in write-ready shape (markerAndContent), with the same round-trip
guarantee as the library — what a scope returns is what a vault_patch
replace at that scope consumes. The description spells out the shape
per target type, including that a heading markerAndContent read comes
back with its own line as '# Title', levels relative to its parent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document scoped reads across the OpenAPI docs and README

Adds the read-side Target-Scope header parameter to every GET that can
address a sub-part of a note, extends the targeting overview and the
GET 400 description to cover it, and gives the README's targeting
section a markerAndContent example — the read-modify-write shape that
motivated the feature, since a heading subtree reads back with its
levels already relative to its parent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add integration coverage for Target-Scope reads

Exercises the scoped-read surface against live Obsidian: marker and
markerAndContent on heading, block, and frontmatter targets, the
InvalidTargetScopeHeader rejection of 'parent', and the guarantee the
feature exists for — a markerAndContent read fed straight back through
a markerAndContent PATCH replace leaves the file byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Depend on the published markdown-patch 2.0.0 instead of the local sibling checkout

During development of this branch, markdown-patch-2 was aliased to a
file: dependency pointing at the sibling markdown-patch working copy,
which broke CI (the package could not be installed there, leaving its
types unresolvable and failing lint). Now that markdown-patch 2.0.0 is
published to npm, the alias resolves to the registry tarball via
npm:markdown-patch@^2.0.0. The v1 dependency remains in place for the
legacy PATCH API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
A
Adam Coddington committed
9201468792cdcaa9446903ecd6b50681745dfbf4
Parent: 04aae05
Committed by GitHub <noreply@github.com> on 7/24/2026, 2:41:00 AM