Skip to content

tutorial

Chapter 5 of 6

Chapter 5 — Revision as Diff

by Rod Rivera Published

One changed field diffs as one field, history is append-only, and a source that moves under a stored figure makes the renderer refuse.

Run this:

make diff

It re-points the total portfolio value at the cash line of the same valuation record — a plausible mistake, and one a document without a provenance table would hide completely:

One field changed:
  field  : total_value
  before : 486210.44
  after  : 21804.1
  cited  : Custodian position extract · VAL-2026-08-29-PF4402 · cash_gbp

What moved in the document:

  -| Total portfolio value | £486,210.44 |
  +| Total portfolio value | £21,804.10 |

  -| total_value | £486,210.44 | Custodian position extract · … · total_value_gbp |
  +| total_value | £21,804.10 | Custodian position extract · … · cash_gbp |

  +| 22 | total_value | 486210.44 | 21804.1 | adviser asked to show the cash line instead |

Three things moved, and each one is doing a job.

The figure changed. The citation changed alongside it, which is the part a generated document cannot offer — the diff shows not just that the number moved but that it is now coming from a different place. And a revision row was appended, carrying the reason.

One field changed means one field diffs

The property is asserted, over the whole document body:

def test_one_field_changed_means_one_field_diffs(self):
    changed = [(b, a) for b, a in zip(before.splitlines(), after.splitlines()) if b != a]
    self.assertTrue(changed)
    for b, a in changed:
        self.assertTrue("risk_profile" in b or "Risk profile" in b,
                        f"an unrelated line changed: {b!r} -> {a!r}")

This is only checkable because rendering is deterministic. It is also the test that catches an entire class of bug that is otherwise invisible: a renderer whose output depends on iteration order changes lines nobody touched, and in a document that a client compares against last quarter’s, that is indistinguishable from a real change.

The history is append-only

def _append(state, key, before, after, reason):
    return state.revisions + (
        Revision(seq=len(state.revisions) + 1, field_key=key,
                 before=before, after=after, reason=reason),
    )

seq is derived from the existing length rather than stored separately, so a revision cannot be inserted with a sequence number that lies about when it happened. And DocumentState is frozen: edits return a new state rather than mutating one, because “what changed between version 3 and version 4” is unanswerable against a mutable object, and that question is the entire value of a document a regulator may later ask about.

A silent overwrite is refused outright:

def test_a_change_without_a_reason_is_refused(self):
    with self.assertRaises(EditRefused) as ctx:
        set_sourced_field(..., reason="   ")
    self.assertEqual(ctx.exception.code, "overwrite_without_reason")

Deleted from the body, kept in the history

Clear a field and it vanishes from the document body — but the history still records what it was:

def test_the_deleted_value_survives_in_the_history(self):
    state = clear_field(build_fixture_state(), "objective", reason="withdrawn").state
    history = render_markdown(state).split("## Revision history")[1]
    self.assertIn("Draw a steady income", history)

Worth pausing on, because this pair of tests started life as one failing assertion. The first version of the proof script asserted that a deleted value appeared nowhere in the document, and it failed. The failure was correct: the value was gone from the body and preserved in the audit trail, which is exactly what an append-only history is for. The assertion had conflated two different guarantees.

The body is the document. The history is the audit trail. They answer different questions and a test that checks both at once checks neither.

The load-bearing case: when the source moves

Everything so far assumes the source records stay put. They do not.

A state object built this morning, carried through a conversation, and rendered this afternoon holds figures that were true when they were read. If the custodian extract is corrected, restated, or tampered with in between, the document would otherwise report this morning’s numbers under this afternoon’s citations — every figure footnoted, every footnote wrong.

So before rendering anything, docpkg/verify.py re-resolves every citation in state and compares:

def require_intact_provenance(state: DocumentState) -> None:
    mismatches = verify_state(state)
    if mismatches:
        raise ProvenanceBroken(mismatches)

One call, at the top of render_markdown. Mutate the extract and:

REFUSED: 1 figure(s) no longer match the record they cite. The document was
not rendered.
  total_value: document says '486210.44', source says '911000.0'
      cited as: Custodian position extract · VAL-2026-08-29-PF4402 · total_value_gbp

Note that it does not render blank. Blank is the right answer for a field that was never sourced; a field whose citation has stopped agreeing is a different condition, and quietly blanking it would hide a changed record behind what looks like an incomplete document. The whole render refuses, names every disagreeing field, and exits non-zero.

A deleted source record is refused the same way, and the refusal reaches the agent as a refusal rather than a crash:

result = td.render_document().llm_response
self.assertFalse(result["ok"])
self.assertEqual(result["refused"], "provenance_broken")

The guard has been watched failing

A guard nobody has seen go red is a docstring, not a guard. So it was removed — one line, deleted from render_markdown — and the proof re-run:

2. REFUSE — mutate the provenance table and the renderer refuses
  PASS  the unmutated state renders
  FAIL  the renderer REFUSED the mutated provenance
        IT RENDERED. A document was produced whose citations do not hold.
  FAIL  a DELETED source record is refused too
        it rendered

EXIT WITH GUARD REMOVED = 1

With the guard gone the renderer emitted a complete, well-formatted document stating £486,210.44 while the extract said £911,000. It looked exactly like the correct one. The guard was restored and the proof returned to exit 0.

And this guard caught a real bug on its first run, before any test existed. set_negotiated_field originally cited an unrelated disclosure record, for want of anywhere better to point a conversational choice. verify_state refused the entire document immediately: stored value client_and_adviser, cited record DISC-SCOPE-004, and they did not match.

The guard was right and the citation was a lie. Borrowing a citation from a record that does not justify the value is precisely the failure this design exists to prevent — and it took ten minutes to commit it by accident, while writing the thing that prevents it. CONVERSATION_SOURCE in docpkg/sources.py is the fix, and the comment above it says why it is there.