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.

The analogy to hold the whole way down: a museum with a new wing. The curators (the extraction pipeline) have already hung every painting in a back room, each with its exact wall-coordinates recorded. But there is no corridor from the public gallery to that room (half 1: the read endpoint is the corridor). Separately, the audio-guide (the agent) has been upgraded to say “you are now standing in front of this canvas” — but the ticket turnstile (the WS envelope) rejects any visitor who tries to tell it which room they walked into (half 2: accept and forward 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.

HalfProducer (already there)The missing seam this Story addsConsumer (already there)
Blocks readdocument_pages.blocks JSONB (written by the OCR/native/tabular persist paths)GET /app/documents/{doc_id}/blocks — a read route + service + response DTOthe Document Page (gutter marks, bbox overlays, anchors)
WS orientationthe app client, which knows the surface counsel is viewingan optional surface_context field on the WS envelope, forwarded into the AgentCommand buildthe 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.

Why re-validate our own data through 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:

PropertyMechanism
Legacy messages never 422the 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 absentAgentCommand’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 kindany 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

FileChange
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.pyread_document_blocks() + _current_firm_id() helper; reuses the existing _list_pages() primitive
src/app_api/router_documents.pylist_document_page_blocks route (operationId appListDocumentPageBlocks — renamed off the matters-router list_document_blocks to avoid an operationId collision)
src/app_api/router_ws.pyimport SurfaceContext; add the optional field; pass surface_context=message.surface_context into the command build
openapi/app-api.json, openapi/permission-map.jsonregenerated; 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:

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.

The staging curl of the real doc 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.