Story 2734: Expose the Indigo understanding envelope on the app-api read model

Story-2731 (PR #738) built the write side of Indigo (Epic-2624): the packet producer, the broker invoke, and persistence of the returned document_understanding.v1 envelope into context.document_understandings. This Story is the read side: one firm-scoped GET that serves that persisted envelope to the app so it can render the "Indigo read this." card (indigo-rfd §7) and the Insights meaning surface. Out of scope and unchanged: app rendering (a sibling app ticket), triggering reads, and registry-card CE wiring. The precedent this mirrors is Story-2660 (Blake card exposure) and, more closely, the insight brief read door.

Why this shape

The envelope is deliberately opaque on the functions side (agent_contracts/indigo.py): its vocabulary - episodes, findings, attention_map, assertions, coverage - grows by a schema bump on the agent, and mirroring the whole tree here would make the broker a second authority that silently drops a legitimately-extended envelope. So the read re-validates only the header (schema, document_id, extraction_version, prompt_version) through validate_understanding_envelope - the HR-21 "stored JSONB is re-validated, never cast" guarantee at the level this repo owns - and passes the body through as-is. The compact card is a code projection that reads known keys defensively; a version-evolved envelope that renames one degrades that card field to null rather than 500-ing the whole read.

1. Where things live

ConcernFile
Read door + response DTOs + card projectionsrc/shared/context/understanding_read.py (new)
Route wiringsrc/app_api/router_documents.py
Contract snapshotsopenapi/app-api.json, openapi/permission-map.json
Contract test against a seeded rowtests/integration/test_app_understanding_route.py (new)

2. The endpoint

GET /app/documents/{doc_id}/understanding   -> 200 DocumentUnderstanding
                                              -> 404 pramaan.context.understanding_not_found

Guarded by require_permission("matters:read") and firm-scoped through the caller's RLS session (AppDbDep) - the same perimeter every other document read uses, so a plain WHERE document_id = :id only ever returns the caller's own firm's row. The triple key (document_id, extraction_version_id, prompt_version) can hold several rows (a prompt bump or a re-extraction writes a new one), so the read serves the newest envelope - the exact shape the migration-0194 firm_document_generated index was built for.

3. The payload

FieldSource
card.summaryregistry_card.summary (the gate-trimmed document_story.what_happens), fallback to document_story.what_happens
card.readFirstAnchorIdsenvelope read_first_anchor_ids
card.confidenceenvelope-level, code-derived confidence
card.status / promptVersion / modelId / generatedAtprovenance columns on the row
envelopethe full document_understanding.v1 body, served verbatim (header re-validated)
gateassembly-gate stats, served verbatim
The card and the full body ship in one payload: the card so the app renders the compact surface without walking the tree, the envelope for the Insights surface. The card is a convenience projection - never the authority - so the app can trust the envelope body and the card equally.

4. Empty state

Absent envelope raises PramaanNotFoundError -> 404 with code pramaan.context.understanding_not_found. The app reads that as "Indigo has not read this yet" and renders its not-yet-read state, exactly as the insight brief door does.

5. The six review-gate answers

QA
New cloud capability?None. A read of an existing table over the existing app-api DB path.
Who grants it / where?N/A - the pramaan_app SELECT grant on context.document_understandings already shipped with migration 0194 (Story-2731).
What check goes red if absent?test_app_understanding_route.py fails (404 on a seeded row, or an RLS leak).
Observed red?Yes - the RLS-isolation arm was observed failing before the firm-scope was correct; the 404 arm fails on any read-path regression.
What legacy is deleted?Nothing. This is net-new read exposure; there is no prior understanding read door to retire.
Deploy smoke exercises the change?N/A for a read route - the DB-backed contract test seeds a real row and exercises the exact code path.

6. Verify

uv run ruff check                              # clean
make openapi && make gen-permissions           # zero drift after commit
pytest tests/integration/test_app_understanding_route.py   # card + full body + 404 + RLS isolation