SourceSpan in JSONB, a graph node carries span ids, the app renders a
bundle it fetched minutes ago. Then the document gets re-OCR'd — a better engine, a
rescan — and document_pages rows are replaced. The same
(page 4, block 2, chars 10-40) coordinate now addresses
different bytes. If the app highlighted that paragraph on trust, it would
show counsel a confidently-wrong passage as the evidence — and a court
strikes the evidence the instant the quote is not in the document. CE-17 is the
resolve-time gate (RFD §4 verification-at-resolve): before anyone
trusts a stored citation, cite re-checks its quote against the
live pages and returns verified. A drifted citation comes back
verified=false and renders as "needs re-verification" — never a wrong
highlight.
One thin HTTP door — POST /app/matters/{matter_id}/context/cite — takes
the (possibly stale) span a consumer is holding, re-verifies it byte-for-byte against
the current page rows, and hands back the fresh, server-built deep link to the
document permalink page. Nothing trusts the client's copy: the server recomputes
verified and rebuilds the link. It reuses CE-2's byte-exact resolver —
one verification semantic in the whole codebase (RFD §4: "the live
V-CITE helper, reused").
| Step | What it does | RFD |
|---|---|---|
| Authorize | require_permission("matters:read") gates the route; one RLS-scoped query is the explicit matter-access check — the citation resolves only inside a matter the caller can see, to a live non-privileged Record document that belongs to that matter. | §8 (three auth layers) |
| Load live pages | list_pages reads the current documents.document_pages for the span's document (firm-scoped by RLS). | §4 "against the live page rows" |
| Re-verify | resolve_span (CE-2) slices the anchored unit and requires the quote to be byte-exact / byte-present against the live text. | §4 verification-at-resolve |
| Build link | The deep link is built server-side, after resolution — coordinates only, never a raw S3 key, never client-side from an unverified span. | §8 deep-link contract v1 |
| File | Role |
|---|---|
src/shared/context/cite.py | The executor + deep-link builder. Pure: takes the span + already-loaded pages + matter_number, returns the CiteResolution wire DTO. One implementation, reachable by both the HTTP door and (later) the broker's in-process stamping. |
src/app_api/router_context.py | The first HTTP door onto the Context Engine's tool catalog (/app/matters/{id}/context/*). Owns authz, the matter-access / document-membership check, and page loading. |
src/shared/context_contracts/tools.py | The CiteResolution DTO already existed (CE-1). This story pins its deep_link contract v1 to the shipped app route. |
The document permalink page shipped tonight as
/matters/[matterId]/record/doc/[documentId] — and its
[matterId] segment is the per-firm matter_number, not the
internal UUID (the app parses it with parseMatterRouteId). So the link
the server builds must speak that route, or it 404s — a door welded shut. The
contract v1 carries the coordinates the record-doc viewer's fact-jump + highlight seam
consumes (page always; block when the span anchors one):
/matters/{matter_number}/record/doc/{document_id}?page={page_number}[&block={block_index}]
?…/record/documents/… string changed.
The RFD sketched the contract before the page shipped. Repo doctrine (AGENTS.md
"done = system boundary") outranks a stale sketch: a link that 404s is not done. The
canonical v1 now matches the real shipped matter.record.doc
route, and the CiteResolution docstring is updated to match.
engine_state is always live — the backing
(document_pages) is real relational data. A verified=false
is a fact about this one span (it drifted), not a claim the engine is
unbuilt (RFD §7 honesty). The resolver fails closed on every drift mode:
re-OCR shrank the doc → page_number gone → verified=false re-segmented the page → block_index in range? → out of range → verified=false text edited under offsets → sliced bytes ≠ quote → verified=false nothing ingested yet → zero pages (first run)→ verified=false quote still byte-present → verified=TRUE + deep link
| Criterion | Where proven |
|---|---|
stale-span returns verified=false | test_app_context_cite.py::test_cite_stale_span_returns_unverified (quote drift + missing page), and unit test_context_cite.py (quote drift, missing page, empty first-run). |
| authz rejection test | test_cite_without_matters_read_is_forbidden (support role → 403); test_cite_cross_matter_document_is_not_found (document not on this matter → 404). |
| snapshots committed | openapi/app-api.json gains contextCite (admin snapshot unchanged). |
| byte-exact happy path (real POST, real DB) | test_cite_byte_exact_verifies_and_builds_deep_link — verified=true, engine_state=live, correct deep link. |
cite re-reads the bytes at click time so PRAMAAN never highlights
a stale citation as if it were still the evidence.