Also in 中文. Part 7 of the Engineering LLM Applications series. The evidence base is the same as in earlier parts: the implementation, devlogs, and test suite of a production-grade AI writing agent (FastAPI + React + Tauri).
The problem: the model edits the document — what guards the document?
A typical incident from live testing: the user types feedback like “polish this”, “fix the bibliography”, “change Table 1” into the chat box, and some of these silently rewrite the entire document — the result lands directly as a new version, and the user discovers only afterwards that the change went far beyond what they asked for. In this class of application, the document is the user’s core asset, and every model output can touch it. Translating the problem from “model behavior” into “data model”, the requirements converge on four:
- Every version recoverable — the state before any rewrite can be brought back;
- Every edit rejectable — model output is a proposal first, and becomes fact only when the user accepts;
- Every edit addressable — “change only this section” requires a deterministic answer to “which bytes are this section”;
- Review cost bounded — the user must be able to see what changed before deciding.
None of these can be guaranteed by prompting. The rest of this article walks through our implementation layer by layer: the version chain, the proposal ledger, the sectionizer, and the division of diff labor.
The version chain: restore forks, never overwrites
The storage layer is one document_versions table (SQLite, single-user local application):
CREATE TABLE IF NOT EXISTS document_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
version_no INTEGER NOT NULL,
content TEXT NOT NULL, -- LaTeX source
change_summary TEXT,
parent_version_id INTEGER,
...
);
Three design points:
Full snapshot per version, no deltas. content stores the entire source. The cost is storage redundancy; the payoff is that reading any version requires zero replay and both ends of any diff are always materialized. For LaTeX documents in the tens-of-kilobytes range, this trade-off is not close.
version_no is numbered per session. add_version takes MAX(version_no)+1 within the session: the user always sees this document’s v1/v2/v3, never a global v847; the global autoincrement id exists only for foreign keys and lineage. A dedicated test pins this: interleave versions across two sessions, and the second session’s first version is still 1.
parent_version_id records lineage; restore = fork. The restore endpoint in its entirety:
@app.post("/api/versions/{version_id}/restore")
def restore_version(version_id: int, ...):
"""Restore/fork: create a new latest version from a historical
version's content (parent points at it)."""
v = db.get_version(version_id)
...
ver = db.add_version(v["session_id"], v["content"],
f"restored from v{v['version_no']}", v["id"])
Restore deletes and overwrites nothing: it creates a new version from the restored version’s content, with parent pointing at the restored version. The test assertion nails the semantics down: restoring v1 after v2 exists produces v3 — content equals v1’s content, version_no only ever increases, parent_version_id points at v1. “Undoing a restore” is just another restore; every operation is an append.
There is no UPDATE content path anywhere in the backend; manual edits also go through add_version. Immutability is the foundation everything else stands on: diffs get stable endpoints, and proposals get a well-defined base.
The proposal ledger: edits land in the ledger first, the tree only on accept
The first implementation used “plan A”: candidates were not persisted. edit-section computed but did not store; the frontend previewed the diff and called the save endpoint itself — zero new tables, no contact with the version tree. Its limits showed quickly: a page refresh lost the candidate, multiple candidates could not coexist, and the server had no audit trail. Hence the upgrade to “plan B” — the proposed_edits ledger:
CREATE TABLE IF NOT EXISTS proposed_edits (
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
base_version_id INTEGER NOT NULL, -- which version the edit is based on
char_start INTEGER NOT NULL,
char_end INTEGER NOT NULL,
old_text TEXT NOT NULL DEFAULT '',
new_text TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', -- pending/accepted/rejected
scope TEXT NOT NULL DEFAULT 'section', -- section/whole/bib
...
);
Every edit the model produces — section-level, whole-document (scope='whole', range [0, len]), bibliography — first becomes a pending candidate; the version tree is touched only on accept, and accept reuses the same add_version:
The accept path carries two 409s: candidate already processed (accepted/rejected cannot be re-processed), and base is stale (base_version_id no longer the latest version). The second is the critical one: a candidate’s character range is computed against its base version; splicing against a base that is no longer the latest would silently drop every change between base and head. An adversarial review had found the same failure on the frontend — editing on an old version and accepting would overwrite subsequent versions — the frontend gained an “edit only on the latest version” gate, and this 409 is the server-side backstop.
Whether every edit should require confirmation got its own round of sourced interaction research. The conclusion first corrected the risk framing: a full rewrite is in fact already revertible (the version chain exists); the real risks of a silent default are scope surprise, cost, and expensive after-the-fact review — not data loss. So the confirmation gate should be light (a banner plus a plan card), not blocking modals everywhere; small section-level edits keep one-click accept. An industry counter-example is on record too: Cursor weakened its diff-approval-style confirmation and the community treated it as a regression (taken from our research notes, not independently verified). The direction matches the HITL literature: confirm only irreversible actions and prefer undo (NN/g); when unsure, narrow the scope of action and disambiguate before acting (Microsoft HAX G10).
The deterministic sectionizer: equal-length masking and dual anchors
Where do a candidate’s char_start/char_end come from? A deterministic sectionizer (~260 lines, zero LLM calls, zero third-party dependencies) parses LaTeX source into a node tree along \section / \subsection / \subsubsection. A node’s editable span runs from its heading command to the next heading whose level is not deeper than its own — editing a subsection replaces only that subsection; editing a parent section takes its children with it. Three design points:
Mask, don’t shift. A \section inside a comment or a verbatim environment (verbatim / lstlisting / minted, …) is a false heading; deleting those regions before parsing would destroy every character offset. The fix replaces them with equal-length whitespace:
def _mask(content: str) -> str:
"""Replace comments and verbatim environments with equal-length spaces
(all character offsets preserved), so a \section inside them is not
treated as a real heading — while splice still uses original offsets."""
Headings are located on the masked text; titles and content are read from the original at the same offsets. Parsing and splicing share one coordinate system — no translation step, no translation bugs.
Dual anchors. Each node carries both a positional anchor (n0, n1, … in order of appearance; re-parsing the same content always yields the same result) and a content-fingerprint key (L1:ErrorAnalysis, derived from level + title, with an ordinal suffix when titles repeat). Positional anchors drift when sections are inserted or removed; content keys follow the title and are more stable across versions, so they take precedence during resolution. Abstract and keywords have no \section to address, so pseudo-nodes are constructed from the \abstract{...} / \keyword{...} commands — “fix the abstract” also gets scoped editing instead of collapsing into a full rewrite.
Parse failure degrades, never raises. If no section structure parses, the sectionizer returns editable=False and the caller falls back to whole-document revision. The sectionizer’s job is to narrow scope, not to add a failure point.
The robustness of this layer was largely ground out by adversarial review; three real defects, each now pinned by a regression test: the optional-argument heading form \section[short]{long} was missed (the section merged into its predecessor and was silently lost on accept); \section inside comments and verbatim environments produced ghost nodes (corrupting the splice); and the stale-version overwrite problem described above.
Splice, fragment health checks, and a lesson from the reflection loop
A model-returned section fragment travels back into the master document through three all-code steps: strip_fragment cleanup (remove Markdown fences; if the model disobeyed and returned a full document, cut out the body — the general approach to output cleanup is Part 3 of this series); check_fragment health checks (balanced braces, balanced environments, no \documentclass — soft signals that warn rather than block); and finally a one-line splice:
def splice(content, char_start, char_end, new_text):
return content[:char_start] + new_text + content[char_end:]
Bytes outside the range are unchanged — that promise is written directly into the test assertions.
One lesson deserves its own paragraph. The autonomous reflection loop’s scoped splicing used positional anchors: if a fragment carried one extra top-level \section, the section count went up by one after splicing, the positional anchors of every subsequent edit in the same round drifted, and the wrong sections got edited — while the regression gate of the time only caught “fewer sections”, not “more”. Silent mis-edits. The fix adds a conservation law to the splice: a fragment must preserve the count of top-level \section commands within the replaced span, otherwise that section is skipped. This too came out of adversarial review, and is now a permanent test.
The division of diff labor: the backend guarantees invariants, line-level rendering goes to the frontend
With versions and candidates in place, “see what changed” was deliberately not built as a backend service:
| Layer | Responsibility | What it does not do |
|---|---|---|
document_versions | Immutable versions, queryable lineage | No diff computation |
proposed_edits | old_text / new_text archived as a pair | No presentation |
split_aligned | Split a whole-document change per section when skeletons align | Returns None on structural change |
Frontend diffLines (jsdiff) | Line-level highlight rendering | Never mutates data |
split_aligned is the only place the backend “computes a diff”, and its conditions are deliberately tight: the top-level \section skeletons of base and new must have the same titles, same order, same count, and the preamble must be byte-identical — only then is the whole-document change split into a list of independently acceptable per-section candidates. When skeletons align, the section ranges are non-overlapping, so accepting one section replaces only that section and leaves everything else byte-for-byte untouched. If any condition fails, it returns None and the change falls back to whole-document all-or-nothing. The preamble check looks strict, but the reason is concrete: if a change lands outside every section, per-section review would miss it.
Line-level diffing goes to jsdiff’s diffLines on the frontend. Display granularity is a UI concern that iterates with the design; the backend promises only testable invariants — immutable versions, bytes-outside-range unchanged, and the safety conditions for splitting.
Quantified: how tests pin these semantics down
| Semantics | The test / data that pins it |
|---|---|
| Restore = fork | After restore: content equals the source version, version_no only increases, parent points at the restored version |
version_no per session | Interleaved versions across two sessions; the new session’s first version is still 1 |
| Candidate lifecycle | 409 on re-processing; 409 on stale base |
| Sectionizer robustness | 29 P0/P1 tests (including 4 added after review); full suite at the time: 173 passed |
| Per-section review | 6 new split_aligned tests; full suite at the time: 266 passed |
| Ownership isolation | Non-owners get 404 on version reads and restore |
All of these run with a mocked LLM, fully offline — the data-model layer is precisely the part of an LLM application that conventional assertions can test to death.
Where not to do this
- Full snapshots have a ceiling. Storing the whole document per version is right for tens-of-kilobyte LaTeX; at megabyte scale or high save frequency (real-time collaborative editing), switch to delta storage or CRDTs — this model is not designed for that regime.
- The ledger is for human-in-the-loop products. If the product is a fully automated pipeline where nobody reviews candidates, the ledger is just an unread queue; what belongs there instead is a deterministic regression gate before anything lands (see Part 11). Likewise, wrapping every low-stakes edit in confirmation is the “crying wolf” anti-pattern NN/g warns about — we keep one-click accept for small section edits and place the confirmation gate only on whole-document changes.
- The sectionizer presupposes parseable structure. On unstructured text,
editable=False: the version chain still works, but scoped editing degrades to whole-document revision. split_aligned’s conservatism is a feature, not a defect. When the skeleton changes it refuses to split: structural changes should not be accepted piecemeal — a half-accepted reorganization is more dangerous than a whole-document replacement.- The version tree is not a branching workflow. Lineage is fully recorded, but the presentation is linear with a single tip; exploring several directions in parallel and merging them is unsupported. For git-like branch semantics, this model provides only the foundation.
References
The following sources come from the sourced research notes (devlog) of our interaction study:
- Eric Horvitz, Principles of Mixed-Initiative User Interfaces (CHI ‘99) — the principled framework for mixed-initiative interaction.
- Microsoft HAX Guidelines — G9 (support efficient undo), G10 (scope services when in doubt / disambiguate before acting), G16 (convey the consequences of actions).
- Nielsen Norman Group guidance on confirmation dialogs — confirm only irreversible actions, prefer undo, avoid crying wolf.
- Google PAIR, People + AI Guidebook — users resist full autonomy on high-control artifacts; explanation serves understanding.
- Cursor community regression feedback on weakened diff approval — taken from our research notes, not independently verified.