# Document versioning and scoped editing

> A document an AI rewrites over and over needs a data model that makes every version recoverable and every edit rejectable. This article dissects four layers from a production-grade AI writing agent: an immutable version chain (restore forks, never overwrites), a proposal ledger (edits land in the ledger first, the version tree only on accept), a deterministic sectionizer (equal-length masking, dual anchors), and a division of diff labor (the backend guarantees invariants, line-level rendering goes to the frontend) — plus the test semantics and the limits of applicability.

- Canonical (HTML): https://kaguc.com/blog/versioning-scoped-edit/
- Date: 2026-07-29


*Also in [中文](/blog/versioning-scoped-edit-zh/). 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:

1. **Every version recoverable** — the state before any rewrite can be brought back;
2. **Every edit rejectable** — model output is a proposal first, and becomes fact only when the user accepts;
3. **Every edit addressable** — "change only this section" requires a deterministic answer to "which bytes are this section";
4. **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):

```sql
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:

```python
@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:

```sql
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`:

```mermaid
flowchart LR
    A[Edit request] --> B{Scope}
    B -->|section / bib| C[edit-section]
    B -->|whole| W[edit-whole]
    C --> P[proposed_edits: pending]
    W --> P
    P -->|reject| X[rejected: version tree untouched]
    P -->|accept| G{base still latest?}
    G -->|no| E[409 candidate stale]
    G -->|yes| S[splice] --> V[add_version: new 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**:

```python
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](/blog/output-sanitizing/) 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:

```python
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

1. **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.
2. **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](/blog/deterministic-boundary/)). 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.
3. **The sectionizer presupposes parseable structure.** On unstructured text, `editable=False`: the version chain still works, but scoped editing degrades to whole-document revision.
4. **`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.
5. **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.

