Principle: get and put are dumb whole-document KV primitives and should stay that way. Structured manipulation (patch, batch, diff, move, reorder, dry-run) is a different concern and belongs on a different verb, not bolted onto put. This note proposes the verb split and works through which HTTP methods — including the extension/WebDAV methods — map naturally onto notes operations.
The governing distinction is where the editing intelligence lives, and it differs by interface:
• CLI (and partly the raw HTTP API): GET → edit the local file → PUT. The note is fetched to a local file, edited with ordinary file-editing tools (a text editor, sed, a script — whatever you would use on any file), and the whole document is PUT back. The cleverness is the local editor, not the server. There is no positional drift here because you are editing real, visible text and replacing it wholesale. This is the primary designed workflow, and it is why put of a whole document is not a fallback but the norm.
• MCP / agent over the API: no local file, so structured ops stand in for file editing. An agent acting through MCP cannot open the document in an editor and run text operations on it — it has only the API. The Tier 2 structured ops (patch/batch/move/reorder/diff) exist to approximate local file editing remotely. They are the constrained substitute for the GET→edit→PUT loop, not a richer alternative to it. This is also precisely where positional drift occurs (see the 2026-06-28 incident) — because remote structured editing lacks the see-it-and-replace-it safety that editing a real file gives you for free.
Governing principle: prefer GET → edit-locally → PUT wherever a file and an editor are available; reach for structured API ops only when they are not. This reframes the rest of this proposal: the structured ops, the two safe-edit strategies, and the safety machinery (dry_run, reorder validation) are all about making the remote path as safe as the local one already is. An agent that can edit a local file should do that and PUT, exactly as the CLI does. The session that motivated this note is itself the evidence: every tangle came from remote structured editing, and every repair was a reconstruct-and-PUT — i.e. falling back to the local-file model.
Motivation: the structural-edit incident on 2026-06-28 (see troubleshooting) showed that conflating "replace the whole value" with "surgically restructure" makes the safe operation and the dangerous operation look the same to the caller. Separating them by verb makes the cost visible at the call site.
Two tiers:
• Tier 1 — basic KV (unchanged). GET /{key} returns the raw document; PUT /{key} stores a whole document; DELETE /{key} removes it; HEAD /{key} checks existence. No block IDs, no ops, no cleverness. These are the primitives everything else is built on and the only ones a minimal client needs to understand.
• Tier 2 — structured operations (new home). Everything that manipulates the interior of a document — patch_meta, insert/replace/delete block, batch, move, reorder, dry-run, diff — moves behind POST /{key} with an op field. This mirrors the existing admin pattern (POST / with op already hosts keys/dump/flush/stop), extending the same idiom from the root to individual keys.
PUT has HTTP-defined semantics: idempotent, "replace the resource at this URI with the enclosed representation". A whole-document store is exactly that. A partial structured edit is not idempotent in general (insert_after twice inserts twice) and does not carry a full representation — so it is semantically POST, not PUT. Keeping them on different methods is not just tidiness; it aligns with what caches, proxies, and any HTTP-literate client already assume.
The ops currently living in the MCP patch/batch layer, re-homed:
POST /{key} {op: "patch", ops: [...], if_rev} single structured edit
POST /{key} {op: "batch", ops: [...], if_rev} multiple ops, atomic
POST /{key} {op: "outline"} [{id, type, preview}] + rev — cheap, no full content
POST /{key} {op: "get_ids"} {document, rev, block_ids} — full read
POST /{key} {op: "move", block_id, after|before} reorder one block by stable ID
POST /{key} {op: "reorder", order: [id, ...], if_rev} set block order to match list
POST /{key} {op: "diff", against: <doc|rev>} structural diff, no write
any of the above + {dry_run: true} compute result, do not persist
Several of these directly answer the incident: move and reorder remove the delete-then-insert juggling that causes positional drift, and dry_run / diff let a caller see the result before committing. All were raised on the todo; this proposal gives them a coherent home rather than scattering them as flags on put.
The reorder op deserves emphasis: it is the strongest answer to positional drift. Instead of computing "after which block" for each move (the move approach, still relative and still error-prone in sequence), the caller states the entire desired order as a list of block IDs and the server arranges the document to match. Workflow: outline → permute the ID array locally → reorder. (get_ids also serves, but pulls the whole document; outline is the intended companion — see below.) It touches no content and no metadata — only sequence — so the metadata-preservation and content-corruption hazards cannot arise by construction.
Semantics: total permutation by default — order must list every current block ID exactly once. The server validates before rearranging and rejects (422) on any duplicate, unknown, or missing ID, reporting which. This strictness is the safety: a forgotten block is an error, not a silent drop, and — paired with if_rev — a block another writer inserted after your get_ids shows up as a missing ID and fails the call rather than being discarded. An optional subset/relative mode (order lists only a contiguous run plus an anchor; unlisted blocks keep their positions) covers the ergonomic case of reordering a few items in a long note without restating all of it. (A note-level analogue — reordering child keys within an index such as CONTENTS — is a natural future extension, not part of this block-level op.)
The reorder idea originated 2026-06-19 (logged in mcp-interface-improvements) and recurred on 2026-06-28. Amended 2026-07-09: reorder is exposed on MCP only. The earlier standing requirement — that it appear on all three interfaces — is withdrawn. It follows from the editing model above: CLI and HTTP callers have a file and an editor, so they reorder blocks by moving lines and PUTting the result. A notes reorder subcommand would be a worse way to do what $EDITOR already does. Structured ops exist to give an agent without a local file something equivalent; offering them where a file exists is redundant surface area. What remains non-negotiable is that the implementation lives once in the server-side core — parity of implementation, not parity of exposure. See Interface Parity below.
Structured ops address blocks by ID, so a caller must first learn the IDs. Today that means get(include_block_ids=true) — a full document read. For an LLM agent, whose scarcest resource is context, reading an entire note in order to move one paragraph is the dominant cost of every structured edit, and it recurs on every verification read-back. get_ids as originally specified does not help: it returns the document too.
outline returns one entry per block — {id, type, preview}, where preview is the first ~60 characters of the block's plain text — plus the document rev. Enough to identify a block and to permute an ID list; not enough to reconstruct the note. Typically an order of magnitude cheaper than the document itself.An IDs-only response was considered and rejected: stripping the text leaves a list of opaque tokens the caller must align against a document it no longer holds, which is precisely the mis-mapping that causes wrong-block writes. The preview is what makes the ID safe to use. The unit of addressing must carry enough of its own content to be recognised.
outline composes with the expect guard (see verb-api-implementation, Phase 1b): the outline hands the caller a prefix, and expect: {starts_with: <prefix>} asserts it back to the server, which rejects the op if the named block is not the one the caller believes. Together they close the loop — cheap addressing plus a server-side assertion that the address is right — without a full read on either side. Verification after an edit is likewise an outline, not a get: check the changed block's preview, not the whole note.Exposure: MCP (and PROPFIND-adjacent on HTTP, if wanted). The CLI does not need it — it has the file.
For risky edits there are two ways to avoid committing a bad result, and both are offered as documented commands — neither is the default. The agent selects per situation; selection guidance lives in README/mcp-tools, not in the API.
• Preview (dry_run / diff) — the server computes the post-edit document and returns it (or a diff) without persisting. Lightweight: no scratch key, no cleanup, no concurrency window. The intermediate exists only in the response. Good for the common case — verify a structural batch before it lands.
• Shadow copy (COPY → edit → check → MOVE back) — COPY the note to a scratch key, edit and verify the copy (a real, readable, render-able document), then MOVE it back over the original. Heavier, but the intermediate is a real object a human or tool can inspect in the browser, and the pre-MOVE original is a free rollback point. Good when the edit is large, needs human eyeballing, or you want a durable checkpoint. Needs no new server smarts beyond COPY and MOVE — an attraction given the get/put-stay-dumb principle.
Three sharp edges the shadow-copy path must handle, all of which fall out of the verb design above: (1) Concurrency — capture the original's rev/ETag at COPY time and make the MOVE-back conditional (If-Match); on 412 a concurrent writer touched the original, so re-copy and re-apply rather than clobber. (2) Metadata — MOVE-back replaces the whole document, so the shadow must carry correctly merged metadata (incremented version, preserved created, today's updated, preserved tags) exactly as an in-place edit would. (3) Atomicity — MOVE must be a true atomic replace; a COPY-back-then-DELETE-shadow sequence can crash midway and leave an orphan scratch key (the orphaning hazard). This argues for implementing MOVE properly rather than emulating it.
The question of "other verbs, including from HTTP extensions" is worth taking seriously — several extension methods map onto notes operations more precisely than a generic POST op, and using the real method buys correct semantics from any HTTP tooling for free.
• PATCH /{key} (RFC 5789) — the textbook fit for partial structured edits. PATCH means "apply this set of changes to the resource", which is exactly what the ops list is. Strong candidate to host patch/batch instead of (or aliased to) POST {op: patch}. The ops array is the patch document; If-Match: <rev> carries the concurrency token (see below). The one caveat is the same fragile-JSON reparsing already noted on the todo — a real PATCH body sidesteps the MCP arg-encoding path entirely, which may incidentally fix the apostrophe bug.
• MOVE /{key} (WebDAV, RFC 4918) — maps onto renaming a key (moving a whole document to a new key), with Destination: /{newkey}. This is a genuine gap today: there is no rename primitive, so renaming a note means put-new + delete-old + hand-fixing every backlink. MOVE makes the rename atomic and is the natural place to later trigger backlink rewriting.
• COPY /{key} (WebDAV) — duplicate a document to a new key (Destination header). Useful for templating (clone a structure note) and for snapshotting before a risky edit.
• PROPFIND / PROPPATCH (WebDAV) — read/write metadata (title, version, tags, updated) without touching content. This is exactly patch_meta. PROPFIND-style metadata-only reads would also let an agent check version/tags cheaply without pulling the whole document — directly relevant to the version-skip optimisation on the todo.
• Conditional headers (RFC 7232) — If-Match / If-None-Match with the document rev as the ETag is the standards-compliant form of the existing if_rev optimistic-concurrency token. Returning rev as an ETag header on GET and honouring If-Match on PATCH/PUT/MOVE would replace the bespoke field with a mechanism every HTTP client already speaks, and yields the same 412 Precondition Failed the design already wants.
GET /{key} read whole document (ETag: rev)
PUT /{key} If-Match: rev store whole document
DELETE /{key} If-Match: rev remove document
HEAD /{key} existence + ETag, no body
PATCH /{key} If-Match: rev structured ops (patch/batch/move/reorder/dry_run)
MOVE /{key} Destination rename key (atomic; later: fix backlinks)
COPY /{key} Destination duplicate document
PROPFIND /{key} metadata only (title/version/tags/updated)
PROPPATCH/{key} If-Match: rev edit metadata only (patch_meta)
POST / admin ops (keys/dump/flush/stop) — unchanged
POST /{key} fallback for ops without a natural method
This keeps Tier 1 (GET/PUT/DELETE/HEAD) exactly as basic as it is today, gives every structured operation either a precise extension method or a POST op, and standardises concurrency on ETags. A minimal client still only needs GET/PUT.
Every Tier 2 operation must be implemented once, in a server-side core. What differs (amended 2026-07-09) is which front ends expose it. Exposure follows the editing model rather than a blanket parity rule: reorder and move are MCP-only, because CLI and HTTP callers have a local file and should GET → edit → PUT instead. patch/batch already exist on both MCP and the REST route and stay there. The old rule — "every Tier 2 op on all three front ends" — is withdrawn as over-general; it was a reaction to genuine drift (patch/batch on MCP, no PATCH documented on REST), and the real target was one core, no duplicated logic, no undocumented divergence. That target stands: anything exposed on a front end must route through the shared core, and anything exposed must be documented. Deliberate non-exposure is not drift, provided it is written down here. The parity audit in mcp-interface-improvements should check for undocumented divergence, not enforce uniform surface.
Non-breaking path: (1) add PATCH /{key} hosting the existing ops plus reorder, leaving the MCP patch/batch tools pointed at it; (2) add MOVE for key rename — highest-value new primitive, nothing provides it today; (3) surface rev as ETag and accept If-Match, deprecating the if_rev field once callers migrate; (4) add dry_run/diff to PATCH. COPY and PROPFIND/PROPPATCH are lower priority — nice once the core split exists. Crucially, none of this touches GET or PUT.
• gdata-server/todo — dry_run, move_block, backlink index, return-block-IDs, metadata-cheap-reads all land naturally in this taxonomy
• mcp-interface-improvements — origin of the reorder op and the three-interface parity requirement
• gdata-server/todo/verb-api-implementation — the ordered build plan that turns this proposal into code; starting note for the Claude Code session
• gdata-server/troubleshooting — the sequential-batch incident that motivates separating whole-document from structured ops
• REST API Reference — current endpoints; this proposal extends them
• proposals/log-structure — the log subsystem's log.append would itself be a POST /{key} op or a dedicated verb under this scheme
See also: proposals/section-editing — nested section blocks + heading/CSS-selector editing (the section.* op family), a concrete instance of this taxonomy. Phase 0 (allow + render the section block) shipped 2026-08-04.