Story 2171: two welded doors — the blocks nobody could read, and the orientation the wire threw away
Why this exists (the catastrophe it prevents). Two prior Stories each merged a contract and stopped one seam short of a working feature — the “contract merged, seam missing” class. (1) The extraction pipeline has been faithfully writing per-paragraph blocks — text, a normalized bounding box, a kind, a confidence — into documents.document_pages.blocks for months. 862 rows sit live for one real document alone. Nothing served them. The Document Page wants to draw a gutter mark beside each paragraph, overlay a box on the source, and anchor a citation to an exact rectangle — and it had no endpoint to ask. The data was in the vault with no door. (2) A separate Story (fn#600) added surface_context — “which page is counsel looking at right now” — to the broker’s command model and taught the worker to consume it. But the actual front door, the WebSocket envelope, still had extra="forbid" and no such field, so a browser that sent surfaceContext got a 422, and even a well-formed launch never carried it through. The broker was listening for an orientation the wire was configured to reject.
surface_context). Neither upgrade does anything until you cut the corridor and reprogram the turnstile.🛰 Satellite view: two independent seams, one repo, zero migrations
Both halves are pure plumbing to already-persisted contracts — no schema change, no new table, one alembic head untouched. Each half connects a producer that already exists to a consumer that already exists.
| Half | Producer (already there) | The missing seam this Story adds | Consumer (already there) |
|---|---|---|---|
| Blocks read | document_pages.blocks JSONB (written by the OCR/native/tabular persist paths) | GET /app/documents/{doc_id}/blocks — a read route + service + response DTO | the Document Page (gutter marks, bbox overlays, anchors) |
| WS orientation | the app client, which knows the surface counsel is viewing | an optional surface_context field on the WS envelope, forwarded into the AgentCommand build | the broker/clerk worker (already reads surface_context, scopes the bundle document-first — CE-14/ME-7) |
✈️ Airplane view (half 1): a read that stays a fixed-query read
The endpoint mirrors its sibling document read routes (/{doc_id}/status-events, /{doc_id}/insight-brief) exactly: resolve the caller’s firm once, confirm the document exists in that firm (404 otherwise, so an unknown id and a cross-tenant id are indistinguishable — RLS confines the existence check), then one ordered SELECT over document_pages. Block enrichment is entirely in-memory — no per-row query — so the query count is a fixed three regardless of page count (ADR-130 DR-1).
GET /app/documents/{doc_id}/blocks (require_permission: matters:read)
|
[1] SELECT app.current_firm_id_or_null() -- who is asking
[2] SELECT documents.id WHERE firm_id=.. AND id=.. LIMIT 1 -- exists in MY firm? else 404
[3] SELECT * FROM document_pages WHERE firm_id=.. AND document_id=.. ORDER BY page_number
|
in memory: for each page with blocks -> OcrBlock.model_validate(raw) -> DocumentBlockItem
|
200 { documentId, pages: [ { pageNumber, blocks: [ {blockIndex, pageNumber, text, kind, confidence, boundingBox} ] } ] }
Two deliberate shape choices. Grouped by page: the response is a list of pages, each holding its blocks in reading order (blockIndex is the 0-based position within the page) — the shape the overlay renderer wants. Empty pages omitted: a text-only native document (pypdfium2, no per-item geometry) persists blocks: [] for every page, so it legitimately returns pages: [] — a 200 empty state the page renders, not an error and not a welded door. The bbox is passed through unchanged in normalized 0..1 coordinates, which is what lets a Textract box and a native box render in the same overlay.
OcrBlock? The blocks are our own JSONB, written by our own persist path — but the read still parses each raw dict through the canonical OcrBlock contract before it crosses the trust boundary, rather than casting the dict straight onto the wire (HR-18). A malformed or half-migrated row fails loud at the boundary instead of shipping a shape the consumer’s zod schema will choke on downstream.✈️ Airplane view (half 2): additive, launch-only, byte-identical when absent
surface_context is added to WebSocketClientMessage as an optional field (default None) and threaded into the one AgentCommand(...) the handler builds. Three properties make it safe to add to a live envelope:
| Property | Mechanism |
|---|---|
| Legacy messages never 422 | the field is optional; an old client that omits surfaceContext validates exactly as before — extra="forbid" only rejects unknown keys, and this key is now known |
| Wire stays byte-identical when absent | AgentCommand’s serializer already drops surface_context when None (same treatment as dock_chat), so a launch without it enqueues a command with no surface_context key at all |
Orthogonal to kind | any launch (intake or dock_chat) may carry it; it is not gated by the kind/dock_chat pairing rule |
browser --WS--> WebSocketClientMessage(extra=forbid) AgentCommand SQS -> clerk worker
{ type, ..., surfaceContext? } --accept--> message.surface_context --forward--> command.surface_context --consumes-->
| (Story-2152: scope
(None -> serializer drops it entirely) bundle document-first)
The broker end already existed (runner.py reads command["surface_context"] and, when its params name a document_id, scopes the pinned context bundle to that document first). This Story only opens the front door so the orientation reaches it.
🔬 Microscopic view: the exact diff
| File | Change |
|---|---|
src/domains/document_page/dtos/document_page.py (new) | DocumentBlocksResponse / DocumentBlocksPage / DocumentBlockItem / BoundingBoxView — ApiResponseModel, camelCase wire, Field(description=...) on every field |
src/domains/document_page/service.py | read_document_blocks() + _current_firm_id() helper; reuses the existing _list_pages() primitive |
src/app_api/router_documents.py | list_document_page_blocks route (operationId appListDocumentPageBlocks — renamed off the matters-router list_document_blocks to avoid an operationId collision) |
src/app_api/router_ws.py | import SurfaceContext; add the optional field; pass surface_context=message.surface_context into the command build |
openapi/app-api.json, openapi/permission-map.json | regenerated; the new route needs matters:read |
Proof it works at the system boundary
Per the repo doctrine, “done” is a real request through the router against a real DB — including the empty first-run state. The integration suite (tests/integration/test_app_document_page_blocks.py) drives the real ASGI app against a freshly provisioned Postgres:
- Empty first-run: a document with zero page rows returns
200 {pages: []}— the door is not welded shut. - Grouped shape: pages inserted out of order come back ascending; a page with no blocks is omitted; full DTO round-trip (not a spot-check).
- Cross-tenant: a foreign firm’s caller gets 404, never another firm’s blocks (real RLS, not a mock).
A live in-process HTTP capture against an 11-page seeded document (mirroring the shape of the real 862-block doc) returns HTTP 200, 11 pages, 33 blocks, and emits the route span/EMF metric for /app/documents/{doc_id}/blocks. The WS half is covered by four boundary tests: the envelope accepts surfaceContext (no 422), omits it by default, forwards it verbatim into the enqueued command, and drops it entirely when absent.
3ee1dc28 follows deploy — staging runs functions main, and this endpoint ships with this PR, so it cannot answer on staging until merge + deploy. The real-DB integration run above is the pre-merge system-boundary proof.One-sentence recall
Story-2171 cuts the two last seams left by earlier contract merges: a fixed-query, RLS-scoped GET /app/documents/{id}/blocks that finally serves the paragraph blocks the pipeline was already writing, and an optional surface_context on the WS envelope that forwards counsel’s current surface into the launch the broker was already waiting to read — both additive, both zero-migration, both byte-identical when absent.