docs(adr): ADR-092 — one write path for a KB node - #624
Merged
Conversation
A node created by kb_create, (kb-create), or received over CRDT has no human edit surface: help_edit_source resolves kb_node_source_file and otherwise reports "No source file". On a hosted KB the client holds no files, so that is the ordinary case. Designing the fix showed the missing surface is the smaller half. Five paths write node content and one is CRDT-correct; the buffer :w / reimport path, the watcher drain, kb_widen_meta and the meta recompose all bypass kb_update_node_with. Concretely: on a host that shared a file-backed KB, editing the .org and pressing :w never broadcasts, and kb_widen_meta discards an edit to a federated-instance member while reporting success. Underneath sits a CRDT bug — set_body/set_title wholesale-replace a YText, so two peers editing the same body converge while duplicating the entire untouched base. Verified empirically. No test caught it: the "convergence" tests only ever edit different fields, and the one same-field test asserts peer-equality, which CRDT gives for free. Decisions: kb_update_node_with is the sole content mutator; CRDT text is updated by character-level diff (reusing the reconcile core the buffer layer already has) rather than wholesale replace; the human edit surface is the node's normalized org source text, not its rendered view and not necessarily a file; editable scope is bounded by what actually syncs, so the properties drawer stays out until front matter is folded into the body CRDT; surface selection is configurable with the default reproducing today's behavior exactly. Rejected with reasons recorded: making the rendered buffer writable (the render pipeline is lossy in two ways), keeping the file as the edit surface (serves none of browser / external-editor / hosted clients), reusing node_to_org + parse_org as a pair (parse_org returns the whole file as the body and drops kind/aliases/todo/priority, so a save cycle doubles the front matter), and encoding node identity in the buffer name (the kb-narrow precedent splits at the first colon and is wrong for every namespaced id). Wiring the projector is explicitly out of scope — that is ADR-029's read side. This ADR is its write side. Also regenerates assets/mae-adr.cozo (92 nodes, corpus validated: no dangling references, no Extends cycles) per the ADR-059 Phase E staleness gate, adds the ADR-092 clause to CLAUDE.md's index, and syncs both Cargo.lock files from 0.14.89 to 0.14.92 (issue #61 fallout — the version bump workflow does not update them). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design record for the KB write-path work. No behavior change — this is the ADR, the regenerated ADR KB, and the CLAUDE.md index entry. Implementation follows in phases, each with a test that fails first.
What prompted it
A node created by
kb_create,(kb-create), or received over CRDT has no human edit surface.help_edit_source(help_ops.rs:1355) resolveskb_node_source_fileand otherwise reports"No source file for '{id}'". On a hosted KB the client holds no files, so that's the ordinary case.Designing the fix showed the missing surface is the smaller half.
Five write paths, one correct
kb_update_node_with(MCPkb_update, Scheme, commands)kb_ops/nodes.rs:499:w→kb_reimport_filefile_ops.rs:319-336kb_widen_metakb_ops/dispatch.rs:322-347kb_ops/dispatch.rs:344Two are live data-loss paths: on a host that shared a file-backed KB, editing the
.organd pressing:wnever broadcasts and clobbers peer edits; andkb_widen_metamatches onlyself.kb.primary.get_mut(...)with noelse(dispatch.rs:325), then reports success (:369) — an edit to a federated-instance member is discarded while the user is told it saved.The CRDT bug underneath
set_body/set_title(shared/sync/src/kb/node.rs:190/:165) doremove_range(0,len)+insert(0,new)on aYText. Two peers editing the same body from a shared base converge, lose neither edit, and duplicate the entire untouched base — verified empirically:No test caught it.
three_client_concurrent_edits_converge(node_tests.rs:248) has peers edit different fields — its own comment says so — and the one same-field test,two_clients_merge_body(:71), asserts onlya.body() == b.body(). That oracle is worthless here: CRDT gives convergence for free. The meaningful oracle is that the base appears exactly once.The fix already exists in-tree:
TextSync::reconcile_to(text.rs:533), whose UTF-16 offsets already match. KB nodes never got it.Decisions
kb_update_node_withis the sole node-content mutator; every other path routes through it.KbNodeDoc.KbNodeDoccarries onlyid/title/body/tags/links/meta, so the properties drawer stays out until front matter is folded into the body CRDT. Offering a field that silently never reaches peers is worse than the current dead end — it looks like it worked.Alternatives rejected (with reasons recorded)
strip_kb_body_noisedrops drawers/#+keywords;render_kb_bodyrewrites[[T][D]]to bare display text), and the surviving link spans are static offsets invalidated by the first keystroke.node_to_org∘parse_orgas the pair —parse_org(org.rs:75) returns the whole file as the body and hardcodesNodeKind::Note, droppingkind/aliases;todo_state/priorityare emitted but never parsed back. A save cycle doubles the front matter.kb-narrowprecedent (dispatch.rs:296) splits at the first colon, so it's wrong for every namespaced id.Out of scope
Wiring the projector (
daemon/src/projector.rs—Projector::newandset_change_feedstill have zero non-test callers;ProjectionStoreshas no production impl) is ADR-029's read side and stays tracked separately. This ADR is its write side.Verification
make adr-kb— 92 ADRs parsed, corpus validated (no dangling references, noExtendscycles)make pre-commit— fmt + clippy on both workspaces, code-map, heavy-e2e, and the ADR-059 Phase E staleness gate confirmingassets/mae-adr.cozo.sha256moved in the same rangeAlso syncs both
Cargo.lockfiles 0.14.89 → 0.14.92 (issue #61 fallout — the version-bump workflow doesn't update them).🤖 Generated with Claude Code