Skip to content

docs(adr): ADR-092 — one write path for a KB node - #624

Merged
cuttlefisch merged 2 commits into
mainfrom
feat/adr-092-kb-node-write-path
Aug 5, 2026
Merged

docs(adr): ADR-092 — one write path for a KB node#624
cuttlefisch merged 2 commits into
mainfrom
feat/adr-092-kb-node-write-path

Conversation

@cuttlefisch

Copy link
Copy Markdown
Owner

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) resolves kb_node_source_file and 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

Path CRDT op Evidence
kb_update_node_with (MCP kb_update, Scheme, commands) yes kb_ops/nodes.rs:499
buffer :wkb_reimport_file no file_ops.rs:319-336
org-dir watcher drain no same shape
kb_widen_meta no kb_ops/dispatch.rs:322-347
meta-body recompose no kb_ops/dispatch.rs:344

Two are live data-loss paths: on a host that shared a file-backed KB, editing the .org and pressing :w never broadcasts and clobbers peer edits; and kb_widen_meta matches only self.kb.primary.get_mut(...) with no else (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) do remove_range(0,len) + insert(0,new) on a YText. Two peers editing the same body from a shared base converge, lose neither edit, and duplicate the entire untouched base — verified empirically:

Line one.     ← shared base
Line two.
From A.
Line one.     ← shared base, DUPLICATED
Line two.
From B.

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 only a.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

  1. kb_update_node_with is the sole node-content mutator; every other path routes through it.
  2. CRDT text mutation is incremental, never wholesale — one shared diff core for the buffer layer and KbNodeDoc.
  3. The human edit surface is the node's normalized org source text — not its rendered view, not necessarily a file.
  4. Editable scope is bounded by what syncs. KbNodeDoc carries only id/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.
  5. Surface selection is configurable and the default reproduces today's behavior byte-identically.

Alternatives rejected (with reasons recorded)

  • Writable rendered buffer — the render pipeline is lossy twice over (strip_kb_body_noise drops drawers/#+ keywords; render_kb_body rewrites [[T][D]] to bare display text), and the surviving link spans are static offsets invalidated by the first keystroke.
  • File as the edit surface — serves none of the three target clients: a browser can't reach the notes dir, an external editor has no guarantee it's in the workspace, a hosted participant has no files at all.
  • node_to_orgparse_org as the pairparse_org (org.rs:75) returns the whole file as the body and hardcodes NodeKind::Note, dropping kind/aliases; todo_state/priority are emitted but never parsed back. A save cycle doubles the front matter.
  • Name-encoded node identity — the kb-narrow precedent (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.rsProjector::new and set_change_feed still have zero non-test callers; ProjectionStores has 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, no Extends cycles)
  • make pre-commit — fmt + clippy on both workspaces, code-map, heavy-e2e, and the ADR-059 Phase E staleness gate confirming assets/mae-adr.cozo.sha256 moved in the same range

Also syncs both Cargo.lock files 0.14.89 → 0.14.92 (issue #61 fallout — the version-bump workflow doesn't update them).

🤖 Generated with Claude Code

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>
@cuttlefisch cuttlefisch added the release:none Skip version bump on merge label Aug 5, 2026
@cuttlefisch
cuttlefisch merged commit 56bddc2 into main Aug 5, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:none Skip version bump on merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant