Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions ADRs/0023-chat-proposes-edits-the-human-lands-them.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# ADR-0023 — Chat proposes edits; the human lands them, and every landed edit is reversible

- **Status:** Accepted · 2026-11-08
- **Refs:** invariants W1–W5, S4, M1, E3, E5 · ADR-0004, ADR-0005, ADR-0012, ADR-0016, ADR-0022 ·
GH #253 · [docs/proposals/2026-11-08-chat-edits-notes.md](../docs/proposals/2026-11-08-chat-edits-notes.md)

## Context

Chat was built as a reader: it answered from the notes and never touched them, and W2 said so
("B2 never authors the note body"). Daily use asked for more. "Clean up this doc" (GH #253) is a
request for an editing partner, and a partner who can only describe an edit in prose is one I then
have to retype. The pivot is for chat to work the notes with me.

The reason W2 existed still stands: the vault is mine, and B2 must never change it behind my back.
So the question was how a model can author bytes without B2 making an unbidden write. The answer
is to separate *proposing* from *landing*.

Git was considered as the undo store and rejected: the vault happens to be a git repo, but git is
per commit, not per edit; one commit per accepted edit would have B2 writing into `.git`, sweeping
in my own unsaved typing, and handling dirty trees and rebases. B2 keeps no dependency on git.

## Decision

- **The model proposes; it never writes.** The one edit-shaped tool, `b2_edit`, is
`{note, find, replace}`: replace one exact, unique occurrence of `find` in the note body. It
returns a diff or an `error:` result (no match, several matches, cap hit) and touches no file.
Within a turn, proposals compose on a per-conversation working copy the model's reads see.
- **Landing is my command.** An accepted proposal is written by `Vault::apply_edits` through the
existing guarded save (`save_guarded`): revision-checked, body-only, frontmatter untouched,
re-projected model-free. An outside change since the proposal fails as `WriteConflict`. W1 holds
literally: until I accept, nothing has changed.
- **Review-first by default, opt-out per conversation.** Every proposal waits for Apply unless I
switch the conversation to *apply as you go*. That switch is one gesture, session-scoped, and
never a persisted default.
- **Every landed edit is recorded and reversible, one edit at a time.** The record holds the hunk,
the before and after bytes and both revisions. Undo restores the before bytes when the file is
unchanged, else reverse-applies the hunk if its text is still unique, else refuses and shows the
prior text. Never a guess, never a merge.
- **The journal is disposable cache under `.b2/edits/`.** It is B2-held copies of my own bytes;
deleting it loses undo history and nothing else. S4 is clarified: the vault is still the only
truth, and the journal is a cache like the index.
- **Hunks, not whole-note rewrites.** A whole-note rewrite from a small model drops paragraphs
silently; a hunk can only change what it names, yields a precise diff, and reverses exactly.
- **Every edit is visible.** Each proposal renders as a diff card with a status (pending, applied,
undone, conflict); landed edits appear in a Changes list with Undo; the answer can never claim an
edit the ledger does not show (ADR-0022's `tools` principle, extended). Diff text is model
output and renders through the escaping path (E5).
- **No delete, move or rename from chat.** W4 is unchanged. Frontmatter and resources are out of
the tool's reach.
- **The loop and the writes live in `b2-core`** behind façade methods; the CLI and desktop are
dumb adapters over the same proposals, apply and undo (E3, ADR-0012).

## Consequences

- W2 and W3 are amended (see `invariants.md`): B2 may carry model-drafted body bytes, but lands
only bytes I accepted, and the on-command write list gains *apply an accepted edit* and *undo an
applied edit*.
- How well a given model drives `b2_edit` is a quality question for the harness (ADR-0013): clean
match rate, preservation of links and headings, judged quality, honesty when nothing needs
changing. The eval decides whether edit mode is on by default for the shipped model.
- Phase 1 keeps the journal in memory; durability under `.b2/edits/` and the opt-out toggle follow
once undo is proven on the in-memory form.
- `FakeLlm` scripts an edit call so the propose, apply and undo paths are covered model-free (E2).
1 change: 1 addition & 0 deletions ADRs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ records say *why* an entry reads the way it does.
| [0020](0020-embeddings-inside-the-single-binary.md) | Embeddings inside the single binary: candle + hf-hub, provisioned by `b2 init` |
| [0021](0021-concurrency-is-serialized-on-sqlites-own-locks.md) | Many readers, one writer, serialized on SQLite's own locks |
| [0022](0022-the-chat-seam-carries-tool-calls-for-bounded-turns.md) | The chat seam carries tool calls; a tool-using turn is bounded and degrades |
| [0023](0023-chat-proposes-edits-the-human-lands-them.md) | Chat proposes edits; the human lands them, and every landed edit is reversible |
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ missing final newline all survive (W5). Against that backdrop, every byte B2 wri
mechanics of a command you invoked (W1, ADR-0004). The complete list of on-command writes is
[invariants.md W3](invariants.md).

Chat is the one place a model drafts body bytes, and it still cannot write them: a `b2_edit`
tool call is a *proposal*, shown as a diff, landed only by your Apply through the same guarded
save, and recorded so one Undo reverses exactly that edit (ADR-0023).

`b2 mv` never breaks the graph. A note's identity is its path, so a move is a re-key, not a
rebuild: children reference `notes(path)` with `ON UPDATE CASCADE`, so one statement carries
chunks, vectors, and edges across, and inbound link text is repaired in exactly the files the
Expand Down
20 changes: 14 additions & 6 deletions docs/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ drops in without a redesign.
([index-engine.md](index-engine.md) §8,
[GH #170](https://github.com/AlteredCraft/B2/issues/170))
- **S4. No durable B2-derived state outside the Markdown.** No event log, no sidecar files, no
facts that live only in the index. The scope is *B2-derived* data. Your own folder tree is
facts that live only in the index. The scope is *B2-derived* data. The chat edit journal
under `.b2/edits/` (ADR-0023) is disposable cache holding copies of your own bytes: delete
it and you lose undo history, nothing else. Your own folder tree is
vault material, and the filesystem is authoritative for it: folders are never projected, and
the tree listing is a live walk of the disk. ([data-model.md](data-model.md), "Folders")
- **S5. A schema change is a version bump plus a rebuild, never a data migration.**
Expand All @@ -61,11 +63,13 @@ drops in without a redesign.
mechanics of a command you ran (W3). Reading a vault (walking it, projecting it, reindexing
it) writes nothing. `reindex` runs unchanged on a read-only vault, and a git-versioned vault
shows no diff from having been indexed. ([data-model.md](data-model.md) §1)
- **W2. B2 never authors the note body, and never asks it to carry B2 syntax.** The body is
100% your document. The one body edit B2 composes itself is the mechanical move repair:
when a note moves, B2 rewrites the *path text* inside inbound `[[oldpath|alias]]` links so
they keep resolving. It fixes a link you already wrote. It never adds one, and aliases
survive verbatim. (The editor save, `Vault::write`, also replaces the body, but with bytes
- **W2. B2 lands no body bytes you did not accept, and never asks the body to carry B2
syntax.** The body is your document. Two kinds of body bytes come from B2 rather than from
your keyboard, and both land only on your command: the mechanical move repair (when a note
moves, B2 rewrites the *path text* inside inbound `[[oldpath|alias]]` links so they keep
resolving; it never adds a link, and aliases survive verbatim), and a chat edit you
accepted (ADR-0023). A chat edit is a proposal until you accept it; the model's tool call
writes nothing. (The editor save, `Vault::write`, also replaces the body, but with bytes
*you* wrote; B2 is only the carrier, W3.) ([data-model.md](data-model.md) §0)
- **W3. The on-command writes are a short, closed list:**
- `b2 link` appends one `b2_relations:` entry (frontmatter, never the body).
Expand All @@ -76,6 +80,10 @@ drops in without a redesign.
frontmatter bytes, body untouched. B2 owns no line in that block and judges none.
- Import (`Vault::import_file` / `import_path`): the handed bytes copied verbatim, then
projected.
- Apply an accepted chat edit (`Vault::apply_edits`): the same guarded splice, body only,
of hunks you reviewed and accepted (ADR-0023). Refused on a changed revision.
- Undo an applied chat edit (`Vault::undo_edit`): the guarded restore of the recorded prior
bytes, or the exact reverse hunk; refused, never guessed, when neither applies.
- Create, move, and delete of notes, resources, and folders, on explicit command.
- **W4. B2 never deletes, moves, or archives your files on its own.** Consequences of your
edits (orphans, dangling links, hash-matched move candidates) are surfaced, flagged, or
Expand Down
Loading
Loading