Ordered build sequence for the verb taxonomy. This is the executable checklist; the design and rationale live in proposals/verb-taxonomy (read it first). Companion design: proposals/log-structure. Origin and parity requirement: mcp-interface-improvements. Incident that motivated it: troubleshooting.
• proposals/verb-taxonomy — the spec. Note especially the editing-model section (CLI = GET→edit-file→PUT; structured ops only substitute for that on MCP) and the Interface Parity requirement.
• gdata-server/mcp-server — deployment reality, read this or you may edit the wrong files. The live dir ~/py/gdata-server on gravlax is not a git checkout; edits are made in a clone, committed/pushed, then copied to the live dir and the service restarted. The clone path, branch, and remote recorded in the notes may be stale — verify before relying on them. The mcp-server note records ~/tmp/gdata-server-github branch m (remote git@github.com:john-critchley/gdata-server.git, SSH key ~/.ssh/github_gdata_server_ed25519), but confirm the clone exists, is current, and points where expected. Work out the correct setup first and reconcile/update the note if it is wrong — do not assume.
• gdata-server/server/api — current REST surface (GET/PUT/DELETE/HEAD per key; POST-to-root for admin ops keys/dump/flush/stop). This note's mapping extends it.
• gdata-server/notes/usage — current CLI (notes list/read/load -d/delete) and Python module (notes_client). New verbs need CLI subcommands here too.
Do these one standalone change at a time, each fully finished — implemented on the interfaces it is meant to reach, tested, committed, deployed, and verified — before starting the next. Each phase below is independently shippable and leaves the system better than before, so an interrupted session still banks real progress. Do not start a phase until the previous one is committed and working. If you only get through Phase 1, that alone is worth shipping. After each phase, update the relevant docs and tick it off in the todo before moving on.
Every structured operation must be implemented once, in a server-side core, and any front end that exposes it must be a thin wrapper over that core. Amended 2026-07-09: front ends need not all expose every op. Exposure follows the editing model — reorder and move are MCP-only, because CLI and HTTP callers have a local file and should GET → edit → PUT. What is forbidden is duplicated logic and undocumented divergence, not asymmetric surface. The past failure was patch/batch existing on MCP with no PATCH documented on REST — the sin was the missing documentation and the second code path, not the asymmetry. Before writing any handler, locate the existing shared core that patch/batch already call (likely in gdata_server.py or a document-ops module) and add new ops there, not in a transport-specific file. Note gdata_mcp_server.py has its own REST batch route — a separate code path from the MCP batch tool (see mcp-server, 2026-07-08). That duplication is the real target.
Tier 1 (GET/PUT/DELETE/HEAD) stays untouched and dumb. No step below modifies whole-document get/put semantics.
1. In the clone, find where document-mutating ops are dispatched (search for insert_after, replace_block, patch_meta). Identify the function that applies an ops list to a document and the validation/dispatch table (the error string listing supported ops — append_block, insert_block, ...batch, table.* — is a good anchor).
2. Write/confirm a smoke test that exercises get/put/patch/batch end-to-end before changing anything, so regressions are visible. The interface-improvements note already asks for this.
3. Decide the core API signature the three front ends will call, e.g. apply_ops(key, ops, if_rev=None, dry_run=False) -> {document, rev, block_ids, ...}. All new ops become entries in the same dispatch.
The op that would have prevented the 2026-06-28 incident. Spec from the proposal:
• Status check before building: reorder appears to be already implemented on MCP. test_reorder.py exists in the suite (testing), the 2026-07-08 fixes touched "the MCP batch/reorder tool handlers" (mcp-server), and the tool was used successfully on 2026-07-09 (total permutation, if_rev honoured; parameter is order, not block_ids). Verify what exists before writing anything — this phase may reduce to documenting it (README/mcp-tools, server/api) and confirming the validation tests below all pass.
• Signature: {op: "reorder", order: [block_id, ...], if_rev}. Default mode is total permutation: order must contain every current block ID exactly once.
• Validation BEFORE any mutation: reject with 422 (HTTP) / structured error (MCP/CLI) if order has any duplicate, any unknown ID, or omits any current ID. The error must report which IDs are missing/unknown/duplicated. This validation is the whole safety property — do not make it lenient.
• Apply by reordering the content array to match order; touch nothing else (no content edits, no metadata beyond the version/updated bump the caller may request). Because only sequence changes, block IDs are preserved.
• Honour if_rev: a stale snapshot (a block another writer added since the caller's get_ids) shows up as a missing ID and fails — that is correct, not a bug to paper over.
• Optional later: subset/relative mode — order lists a contiguous run plus an anchor; unlisted blocks keep position. Ship total-permutation first; add subset only if needed.
• Exposure: MCP only (amended 2026-07-09). Not CLI, not HTTP. CLI and HTTP callers have a local file and an editor — they reorder by moving lines and PUTting the whole document, which is the primary designed workflow. A notes reorder subcommand would be a worse $EDITOR. The implementation still lives in the shared server-side core; only the front-end surface is MCP. See verb-taxonomy → Interface Parity.
• Tests: total permutation round-trips; missing ID rejected; unknown ID rejected; duplicate rejected; stale if_rev rejected; metadata and block IDs unchanged after a reorder.
• Ship before starting Phase 2: reorder verified/working and tested on MCP, committed, deployed; README/mcp-tools documents the tool and its order parameter; server/api records that reorder is deliberately not exposed on HTTP or CLI, and why.
Motivating incident, 2026-07-09: an agent ran replace_block(block_id=X) having mis-mapped the parallel block_ids array onto document.content, and silently overwrote a heading one position away from the intended paragraph. The batch handler was later reproduced and cleared — it replaced exactly the block named. The defect is in the interface, not the implementation: every valid block ID is a legal target, so a wrong ID always succeeds. Corruption lands downstream with no error at the call site, which philosophy forbids twice over (“errors should never pass silently”; “verify the precondition first … so that a violated assumption aborts immediately at the call site”). Two changes below close it. Neither touches Tier 1.
• expect-guard on every block-targeting op (replace_block, insert_after, insert_before, delete_block). Optional expect field, e.g. {op:"replace_block", block_id:"tt9bk1", expect:{type:"para", starts_with:"Always fetch"}, block:{...}}. Server validates the named block matches expect BEFORE any mutation; on mismatch reject (422 HTTP / structured error MCP+CLI) reporting the block's actual type and opening text. Validate all ops in a batch before applying any — atomic, consistent with existing batch semantics.• Match keys to support: type (para/heading/table/codeblock), starts_with (plain-text prefix of the rendered block), and text (exact plain-text equality). Keep it to these three; a general matcher is over-engineering.• expect is optional for compatibility but SHOULD be supplied by every agent-driven caller. Consider a server config flag to require it.• This is the block-level analogue of if_rev: if_rev guards the document against a concurrent writer, expect guards the block against a confused one.• Tests: correct expect passes; wrong type rejected; wrong starts_with rejected; mismatch in op 2 of a 3-op batch leaves the document wholly unmodified; omitted expect still works; error names the actual block content.
• Implement outline — addressing without reading. POST /{key} {op:"outline"} returns one entry per block, {id, type, preview} (preview ≈ first 60 chars of plain text), plus rev. No full content. This is the intended companion to reorder and to every block-targeting op: an agent's scarcest resource is context, and reading an entire note to move one paragraph — then reading it again to verify — is the dominant cost of structured editing today.• IDs travel with their content. Each entry pairs an ID with the text that identifies it. Do NOT add an IDs-only variant: a bare token list must be aligned against a document the caller no longer holds, which is exactly the mis-mapping above. Considered and rejected — see mcp-interface-improvements.• outline + expect compose: outline yields a prefix, expect:{starts_with:<prefix>} asserts it back. Cheap addressing plus a server-side assertion that the address is right. Verification after an edit is another outline, not a get.• get_ids ({document, rev, block_ids}) stays specified for the full-read case, but is no longer the recommended path. If implemented, return IDs inline in each block object rather than as a positionally-coupled sidecar array — the parallel-array shape is the same class of defect security-interfaces rejects. Reject a put body containing id keys rather than silently stripping them.• Exposure: MCP only. The CLI has the file; HTTP callers have the document. get(include_block_ids=true) leaks Tier 2 structure through a Tier 1 verb — keep as a deprecated alias, superseded by outline.• Tests: outline entry count matches block count; IDs and order match a full read; preview truncates without breaking on multi-byte characters; rev matches; outline performs no write.
• Ship before starting Phase 2: expect honoured on all block-targeting ops, outline live, both on MCP, committed/deployed/verified; server/api, README/mcp-note-editing and README/mcp-tools updated — the editing guide in particular should tell agents to use outline + expect rather than full reads. Sequencing: do 1b before Phase 1. outline is the prerequisite for reorder's documented workflow, and expect is the guard that makes every other structured op safe. The numbering is historical, not an order.
• Add dry_run: true to the core apply_ops path: compute the resulting document and return it WITHOUT persisting (no rev bump, no write). Works for patch/batch/move/reorder uniformly since they share the core.
• Return the post-edit block-ID list alongside the document so a caller can diff/verify.
• {op: "diff", against: <doc|rev>}: structural diff between current and a supplied document or a prior rev; read-only. Lower priority than dry_run.
• Expose dry_run as a flag on the relevant HTTP/MCP/CLI calls. Tests: dry_run leaves rev and stored content unchanged; returned document equals what a real apply would have produced.
• Ship before starting Phase 3: dry_run on all three interfaces, committed/deployed/verified, docs updated.
• MOVE /{key} with Destination: /{newkey} — atomic rename of a whole document to a new key. There is no rename primitive today; this is the highest-value new whole-document verb.
• Must be atomic (single write swap), not copy-then-delete, to avoid orphan scratch keys (the orphaning hazard). This atomicity is also what makes the shadow-copy edit workflow safe.
• MOVE onto an existing destination key: error by default (refuse, do not silently overwrite), with an explicit force option to overwrite. force must still honour If-Match/if_rev on the destination so "overwrite" never means "clobber a concurrent edit". Note the equivalence: force is the atomic form of a manual DELETE dest then MOVE — prefer atomic force so there is no window where neither key holds the document. The shadow-copy workflow is the canonical force caller (it deliberately overwrites the original on move-back).
• COPY /{key} with Destination — duplicate to a new key. Same existing-destination policy: error by default, force to overwrite. Enables templating and pre-edit snapshots.
• Note for later (do NOT auto-do it now): rename leaves incoming links pointing at the old key. Backlink rewriting depends on the backlink index (separate todo item). For now, MOVE renames the document only; document that callers must fix inbound links manually until the backlink index exists.
• Expose on all three: HTTP methods MOVE/COPY (Destination header; Overwrite: T or a force param for the override); MCP rename/copy tools with a force flag; CLI notes mv <old> <new> [--force] / notes cp <src> <dst> [--force].
• Tests: rename moves content+metadata intact and old key 404s; copy leaves original intact; MOVE onto existing key errors by default; force overwrites; force with stale If-Match on destination still rejected.
• Ship before starting Phase 4: MOVE+COPY on all three interfaces, committed/deployed/verified, docs updated.
• Surface the document rev as an HTTP ETag header on GET/HEAD.
• Honour If-Match: <rev> on PUT/PATCH/MOVE/DELETE, returning 412 on mismatch — same semantics as the existing if_rev field, standardised.
• Keep if_rev working as an alias during migration; deprecate once callers move. MCP/CLI keep using if_rev (no HTTP headers there) but it maps to the same core check.
• Ship before starting Phase 5: ETag/If-Match working, if_rev still honoured, committed/deployed/verified, docs updated.
• PROPFIND /{key}: return only metadata (title/version/tags/updated) without the content body — cheap version/tag checks, feeds the version-skip optimisation.
• PROPPATCH /{key}: edit metadata only — this is the existing patch_meta op given a precise method.
• Ship: metadata ops on all three interfaces, committed/deployed/verified, docs updated.
These are not a phase — fold them in opportunistically while in the core, each as its own small commit: make put return the new rev so callers skip a follow-up get; return richer success data (rev + changed/inserted block IDs) from patch/batch; consider append_para/append_entry convenience ops (the log subsystem is the canonical caller). Fix the fragile ops-JSON reparsing (apostrophe bug) by accepting native JSON rather than re-decoding strings — a real PATCH body sidesteps it.
⚠️ Amended 2026-07-09: the earlier wording asked put to return block IDs as well as rev. That violates Tier 1 ("GET/PUT/DELETE/HEAD stays untouched and dumb — no block IDs, no ops, no cleverness") and has been struck. Block IDs come from get_ids, which is Tier 2. Note the live server already returns block_ids from put — existing drift, see mcp-interface-improvements. Decide whether to remove it (breaking) or leave it deprecated; do not build on it.
After confirming the clone location (see Read Before Starting), edit in the clone, commit, push, then on gravlax copy the changed files to the live dir and restart:
ssh gravlax "
git -C ~/tmp/gdata-server-github pull &&
cp ~/tmp/gdata-server-github/gdata_server.py ~/py/gdata-server/ &&
cp ~/tmp/gdata-server-github/gdata_mcp_server.py ~/py/gdata-server/ &&
cp ~/tmp/gdata-server-github/notes_web.py ~/py/gdata-server/ &&
sudo systemctl restart gdata-mcp-server.service
"
Copy whichever files actually changed. Verify: ssh gravlax "sudo systemctl status gdata-mcp-server.service | head -6". If the service port won't bind, a stale process is holding it — find and kill per mcp-server.
• Every op routes through one shared core, with no duplicated per-transport logic. reorder/move/outline are MCP-only by design and that is documented; dry_run, MOVE, COPY reach the interfaces each is meant to serve.
• reorder validation rejects missing/unknown/duplicate IDs with clear errors; MOVE/COPY error on existing destination unless forced.
• Tier 1 get/put/delete/head semantics unchanged.
• REST API note (gdata-server/server/api) and CLI usage note (gdata-server/notes/usage) updated to document the new verbs — closing the documentation-drift gap.
• This note and the parent todo updated: move completed items to Completed, mark the verb-taxonomy item done.