Skip to content

Markdown patch 2.0#301

Draft
coddingtonbear wants to merge 25 commits into
mainfrom
markdown-patch-2.0
Draft

Markdown patch 2.0#301
coddingtonbear wants to merge 25 commits into
mainfrom
markdown-patch-2.0

Conversation

@coddingtonbear

Copy link
Copy Markdown
Owner

No description provided.

coddingtonbear and others added 12 commits July 19, 2026 15:48
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
@coddingtonbear
coddingtonbear marked this pull request as draft July 19, 2026 23:04
coddingtonbear and others added 13 commits July 19, 2026 18:08
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>
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>
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>
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>
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>
…ntent

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>
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>
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>
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>
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>
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>
… 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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant