Story 1869: the quote that cannot lie — one canonical text + the V-CITE invariant
Why this exists (the catastrophe it prevents). The whole product is one promise: when PRAMAAN attributes a quote to a document, that quote is in the document. Break that once and a court strikes the evidence — and once one exhibit is struck for a fabricated quote, every other exhibit we filed is suspect. That is the strike-off vector, and it is self-inflicted in exactly two ways. (1) Two sources. If a document has two extracted texts — a page-anchored one for search and a second, separately-parsed one Blake reads — the two drift, and Blake quotes the second while we cite the first. (2) A re-paginate. If “page 6” is whatever the renderer produces today, upgrading LibreOffice silently slides a citation a lawyer already relied on onto different text. This Story closes both: one canonical text per document, and the V-CITE invariant — any string surfaced as a Blake quote must be byte-present on the cited segment of that one canonical artifact, enforced at the boundary, or the document is held for a human and the quote never becomes evidence.
🛰 Satellite view: remove a source, add a checkpoint
RFD-DOC-002’s panel locked two load-bearing decisions this Story implements (logic only — the schema landed in S1/#1866, single alembic head, no migration here):
| Decision | Move | What it buys |
|---|---|---|
| DR-2 — one canonical text | The segment-anchored page text on documents.document_pages is the sole source for search, Blake, and contradiction. No second parser on the Office path (MarkItDown is absent). derived/document.md is a presentation synthesised from those same page rows, not an independent source. | The two-source drift is gone — there is one text, and V-CITE anchors to it. |
| DR-1 — immutable render anchor | A citation carries (render_id, segment_kind, segment_index) and resolves against the frozen segment map of that render. | A re-paginate mints a new render and can never silently re-point a landed citation. |
| V-CITE — byte presence | At the broker boundary, every quote-bearing citation is verified byte-present on its cited canonical segment; a failure holds the document registration_needs_review and records no effect. | A paraphrased / hallucinated / off-segment quote can never become indexed evidence. |
✈️ Airplane view: where the clerk stands
Blake (the Record Registrar) emits typed tool intents; the broker executor (execute_tool_intent) is the one path by which an intent touches business truth. That is where the clerk stands. A quote rides an intent as a span_ref carrying its citation anchor and the verbatim string. Before any effect is recorded:
intent.span_refs ──▶ _enforce_vcite(document, pages)
│ │
│ any ref with a ▼
│ "quote"? verify_citations(span_refs, document.render_id, pages)
│ no → skip │
│ (no DB load) ├─ render_id != document.render_id → render_id_mismatch
▼ ├─ segment (kind,index) not found → segment_not_found
proceed to ├─ quote bytes ∉ segment bytes → quote_not_on_segment
idempotency + effect └─ quote but no anchor → missing_citation_anchor
│
any violation → hold registration_needs_review,
record NO effect, return held_needs_review
The check runs before the idempotency claim, so a held substrate is never marked registered and a corrected re-run is not skipped as a duplicate. Refs that carry no quote (legacy page pointers) are untouched and skip the page load entirely — the common path pays nothing.
🔬 Microscopic view: the decisions that matter
1. Byte-presence, not “close enough”
A court reads bytes. So V-CITE is quote.encode("utf-8") in segment_text.encode("utf-8") — exact, no normalisation, no case-folding, no whitespace collapsing. The temptation is to be lenient so a near-match passes; that is exactly the leniency that lets “the defendant admitted liability” pass against text that says “the defendant denied liability.” An empty quote or an absent segment is a failure, not a vacuous pass: a quote with nothing to anchor to can never be evidence. The check is byte-exact across scripts — a Devanagari quote is matched on its UTF-8 bytes like any other (DR-7’s multilingual corpus).
2. Why the canonical text is the page rows, not document.md
Blake reads derived/document.md — a convenient markdown rendering. But markdown is a presentation: it prefixes headings with # , strips whitespace, joins blocks. A quote lifted verbatim from the markdown may not be byte-identical to the canonical text. That is the whole reason V-CITE anchors to the page rows (the one canonical text), not to the markdown Blake happened to read. The markdown is the working copy; the page text is the certified transcript. Even a quote Blake pulls from the working copy must trace back to the transcript’s bytes, or it is held. This is what makes “one canonical text” load-bearing rather than decorative: there is exactly one text a quote is allowed to be true against.
3. The render anchor makes “page N” immutable (DR-1)
Citations resolve against a segment map — (segment_kind, segment_index) → text — built purely from the page rows. The map keys on the coordinate, never on the rasterised image, so re-rendering with a different LibreOffice digest produces the identical map as long as the segment text is unchanged: a citation does not move because the image changed. And a citation carrying an old render_id against a document that has since been re-rendered does not silently re-point — it fails render_id_mismatch and is flagged, never moved. The golden-fixture test pins this: the same fixture yields a byte-identical segment map across builds.
4. Held, not dropped — and held safely
A V-CITE failure must do two things: never record the quote as an effect (so it cannot surface as indexed evidence), and hold the document for a human (so the failure is visible, not silent). The executor sets registration_status = 'registration_needs_review' directly — V-CITE is a safety override that outranks the normal registration lifecycle, so the held state wins from any pre-terminal status rather than threading through the transition validator. The returned outcome is the new held_needs_review — the one additive contract change (a ToolOutcome enum value; the app mirror consumes it). Violations log only low-cardinality coordinates and a reason code — never the quote or the document text (ADR-124).
How it was proven
- Unit (
tests/unit/test_vcite.py): byte-presence pass/absent/empty/Devanagari; quote-on-segment passes; fabricated and off-segment quotes rejected;render_idmismatch flagged not moved; missing anchor is a violation; non-quote refs untouched; PDF (render_idNULL) verifies; golden-fixture segment map identical across builds and image-independent. - Integration / system boundary (
tests/integration/test_registrar_executor.py, real schema, RLS aspramaan_app): a quote byte-present on the cited segment records its effect and leaves the documentregistering; a fabricated quote records zero effects and holds the documentregistration_needs_review. - Verify:
ruff(src/tests clean),alembic check(no drift, one headf1a2b3c4d5e6), full registrar suite green across random seeds,make openapi(the singleheld_needs_reviewenum addition).