Skip to content

D125 — Atomic multi-concept mutation batches: concept_batch

Status: implemented.

Context. A full homelab-wiki refactor exposed that Cartographer batches edits only inside one concept (concept_patch.edits, D76) or moves (concept_move.moves, D72), not mutations across several distinct concepts. concept_write accepts one id/frontmatter/body/if_match tuple and gitWrap commits once per successful tool call (docs/concurrency.md), so a cross-page refactor produces many independently committed intermediate states: interruption can leave summaries, backlinks, and companion pages partially aligned, and debounced push coalesces network traffic but gives no filesystem/git atomicity across concepts.

Decision. concept_batch(operations) is a new advanced rw tool — deliberately additive rather than overloading concept_write/concept_patch's stable schemas — accepting an ordered array of write (frontmatter, body, optional if_match: absent means create-only, required to update an existing concept) or patch (required if_match, optional frontmatter shallow-merge, concept_patch's own single/edits Edit-tool semantics) operations over distinct concept IDs. v1 deliberately excludes delete, move, expand, assets, and Map/root curated indexes — concept_move/concept_expand/asset_* stay specialized, and a batch that needs curated-index edits reaches for D122's index_patch instead of a new index primitive. No intra-batch dependency semantics: every operation is independent.

Preflight (internal/mcpserver/tools_write.go) fully materializes every resulting concept's frontmatter and body in memory — rejecting an empty batch, duplicate/invalid IDs, an invalid/missing operation kind, a missing required type, a stale or missing if_match, a missing/ambiguous patch match, an excessive operation count (conceptBatchMaxOps, 50) or aggregate decoded content size (conceptBatchMaxTotalBytes, 512 KiB — kept under the stdio transport's 1 MiB max JSON-RPC line, D125's own request included) — before any file is touched, naming the failing operation by index. mapContractViolation replicates kb.Validate's inline strict-ontology check and the D107 required-field contract per target, honoring D124's machine_path_allow_prefixes-bearing MapContract unchanged: no new contract-resolution path.

The atomicity boundary is a KB-layer primitive, kb.WriteConceptBatch (internal/kb/batch.go), one level below gitWrap: it runs under the single lock gitWrap already acquires via WithGitLock (no second lock). writeConcept's validation/content-building half is factored into prepareWriteConcept/commitWriteConceptPlan so both the single-concept path and the batch path share one implementation. Every target — including any implicit expanded-index stub a brand-new directory would trigger (the same side effect WriteConcept already performs for a single write) — is snapshotted (bytes, mode, existence) before any write; files then commit in order, followed by one summary log.md entry. An afterFiles callback lets the MCP layer keep the live and SQLite keyword indexes in step with the same boundary without internal/kb depending on internal/search/internal/sqlindex: it applies the index updates only after every file and the log entry already succeeded, and on its own failure reconciles the indexes it already touched back to their pre-batch state (captured during preflight) before returning — an intentionally stricter guarantee than the single-write path, where a SQLite error is merely logged, because the persisted index is documented as rebuildable while a concept_batch response is not allowed to be partial. Any of these failures rolls every already-written file (and log.md) back to its exact pre-call bytes and mode, pruning any directory the batch created; no nested git commit is ever opened, so a rolled-back call leaves gitWrap's eventual commit as the only one, and a failed call leaves none.

concept_batch is resourceBatch ("multi-concept-batch") in policy.go: every id in operations is authorized individually via allowedID before preflight reads and reauthorized under the git lock, and one denied id rejects the whole batch without disclosing which — the same non-disclosure guarantee concept_move gives its own moves batch, generalized to writes. It is advanced by default (visibility.go): normal agent sessions reach for concept_write/concept_patch (one concept) or concept_move (renames); concept_batch is large-refactor/operator tooling.

Rationale. A KB-layer prepare/commit split (rather than teaching the MCP layer to replay WriteConcept calls with manual rollback) keeps the single-concept and batch write paths from silently diverging, and confines rollback correctness — including the implicit expanded-index stub, an easy case to miss — to one place. Deferring index reconciliation to a caller-supplied callback, rather than importing internal/search/internal/sqlindex into internal/kb, preserves the existing layering (the KB package stays index-agnostic) while still letting the whole call — files, log, and both search indexes — present as one atomic unit to a caller. Excluding delete/move/expand/index-patch from v1 avoids re-deriving their own, already-solved atomicity and backlink concerns inside a second primitive; a KB refactor that needs them composes concept_batch with those existing tools across separate calls.