Story 1607: read_document_markdown — the broker reads the markdown Blake can't
Why this exists (the catastrophe it prevents). Blake — the Registrar agent — classifies a freshly-indexed document from one field: RegistrarDocumentInput.document_markdown. P1 (Story-1606) made the producer honest: the extraction worker now writes derived/document.md. But that markdown lives in S3, and Blake holds no S3 credentials by design (registrar.yaml truth_boundary holds_db_credentials: false; Blake emits intents, the broker executes — it never touches storage). So there is a credential gap: the artifact exists, but the agent that needs it cannot read it. If we closed that gap the wrong way — handing Blake an S3 reader, or letting it presign — we would breach the leash that keeps the brain unable to reach tenant storage directly. This Story closes the gap on the broker side: a read backend the broker-consumer (P3b) calls with its own credentials, firm/matter/document-scoped, to populate document_markdown before invoking Blake. Blake stays credential-free; the broker is the only thing that reads S3.
2026-06-22-blake-seam-connection-plan.md §5 row P2 + §2 read contract. Parent EPIC-1536 (Spike #1594). Depends on P1 (derived/document.md must exist). This is NOT a brain tool — it is deliberately absent from the leash registry (registry.py) and READ_HANDLERS; the broker drives it directly. No new observability span (ADR-124 auto/caller-span floor — the HEAD/GET ride boto3 T1 auto-instrumentation under the broker-consumer's per-record span, exactly like the worker's derived writes).🛰️ Satellite: the broker reads so Blake doesn't have to
broker-consumer (P3b, later) Blake / Registrar runtime
│ RLS session: SET LOCAL (NO S3 creds, NO DB creds —
│ app.current_firm_id = <firm> emits intents only)
│ ▲
└─▶ read_document_markdown(ctx, session, │ document_markdown
│ s3_client=<broker creds>, bucket) │ (populated input)
│ │ │
│ ├─ _require_document(ctx, session) ── RLS gate: doc visible to THIS firm?
│ │ └─ wrong firm → PramaanNotFoundError ("no access") ── before any S3 call
│ │
│ └─ HEAD firms/{f}/matters/{m}/documents/{d}/derived/document.md
│ ├─ 404 / NoSuchKey ─────────▶ empty contract (present=False)
│ ├─ size ≤ 32 KiB ── GET ────▶ inline (markdown string)
│ └─ size > 32 KiB ───────────▶ s3 pointer (bucket + key, no GET)
└──────────────────────────────────────────────────────┘
The key shape: the S3 key is built from the firm/matter/document ids by the same build_derived_prefix the producer uses (Story-1606) — one shared definition, so the reader can never HEAD a key the writer didn't write. The read enforces tenancy twice: the document must be visible under the caller's firm RLS session, and the key is firm-scoped.
✈️ Airplane: the change surface
| File | Role |
|---|---|
src/shared/document_derived.py (new) | Single source of truth for the derived/ layout: build_derived_prefix + the well-known names (document.md, pages.json, text.txt). The producer (worker) and the new reader both import it — so the S3 key has exactly one definition and cannot drift. (Avoids a shared→worker import inversion too.) |
src/workers/document_extraction/derived.py | Now imports the prefix builder + names from the shared module (re-exported for its existing callers); the duplicated local definitions are removed. Pure refactor, zero behaviour change. |
src/shared/registrar/handlers.py | New broker-side read: DocumentMarkdownRead result, INLINE_MARKDOWN_THRESHOLD_BYTES = 32768, the pure S3 helper _read_markdown_object (HEAD → conditional GET), and read_document_markdown(ctx, session, *, s3_client, bucket) which gates on RLS then reads. |
src/shared/registrar/__init__.py | Exports read_document_markdown, DocumentMarkdownRead, INLINE_MARKDOWN_THRESHOLD_BYTES, ToolContext for the P3b consumer. |
tests/unit/test_registrar_document_markdown.py (new) | DB-free coverage of the S3 logic: inline, boundary (== 32 KiB inline), pointer (no GET), missing→empty, UTF-8 byte count, non-404 error propagates. |
tests/integration/test_registrar_document_markdown.py (new) | System-boundary proof under real pramaan_app RLS: indexed doc → inline markdown; wrong firm → PramaanNotFoundError; visible doc with no document.md → empty contract. |
🔬 Microscopic: HEAD-then-conditional-GET, and the empty contract
Why HEAD first. A legal document's markdown can exceed 32 KiB. We must not load a multi-MB body into the broker's bounded Lambda memory just to decide it should ride S3 instead. So we HEAD for the size, then GET only when it fits inline; a large object returns a pointer to the object that already exists — no re-upload (the producer already wrote it at a deterministic key).
def _read():
from botocore.exceptions import ClientError
try:
head = s3_client.head_object(Bucket=bucket, Key=key)
except ClientError as exc:
code = str(exc.response.get("Error", {}).get("Code"))
if code in {"404", "NoSuchKey", "NotFound"}:
return DocumentMarkdownRead.empty() # missing → empty, NOT an error
raise # AccessDenied/etc. is a real failure
size_bytes = int(head.get("ContentLength", 0))
if size_bytes > threshold_bytes:
return DocumentMarkdownRead(present=True, storage_mode="s3",
s3_bucket=bucket, s3_key=key, ...) # pointer, no GET
body = s3_client.get_object(Bucket=bucket, Key=key)["Body"].read()
return DocumentMarkdownRead(present=True, storage_mode="inline",
markdown=body.decode("utf-8"), size_bytes=len(body), ...)
The S3 calls run off the event loop via asyncio.to_thread on the caller's bounded-timeout client (the worker loop contract): a network fault on the VPC S3 endpoint becomes a fast logged error, never an unbounded socket read (the T4 / Bug-1571 lesson).
Two distinct "nothing here" outcomes — and why they must differ.
| Situation | Outcome | Why |
|---|---|---|
Document is visible to the firm, but extraction has not written document.md yet (the first-run / empty data state) | Empty contract — present=False, no body, no error | Blake simply gets empty document_markdown; the seam keeps moving. A 404 here is normal timing, not a fault. |
| A wrong-firm caller asks for another firm's document | No access — PramaanNotFoundError, raised before any S3 call | _require_document runs an RLS-FORCEd select; the row is invisible under the wrong firm GUC, so the read refuses rather than leak. Distinct from "empty" so a real isolation breach can never masquerade as a benign missing artifact. |
_require_document select under the caller's firm RLS session: the document must be visible to this firm before its derived prefix is ever built. The firm-scoped key is belt; RLS is braces.pramaan_app session against a freshly-migrated Postgres test DB, including the empty first-run state — not just a green unit test against a fixture.
make test-db→ isolated Postgres 16 on:55432, migrated to head.pytest tests/integration/test_registrar_document_markdown.py— 3 passed: indexed doc → inline markdown; wrong firm →PramaanNotFoundError; visible doc, nodocument.md→ empty contract.pytest tests/unit/test_registrar_document_markdown.py— 6 passed (inline, 32 KiB boundary, pointer-without-GET, missing→empty, UTF-8 bytes, non-404 propagates).- No regression:
test_registrar_executor.py+test_document_extraction_worker.py+test_derived_artifact.py— 30 passed (the shared-key refactor left the producer untouched in behaviour). ruff checkclean on changed files.mypyon the new/changed source files surfaces only pre-existing SQLAlchemy-typing noise in unrelated transitively-imported files (local-only, non-CI).- No route/DTO/trust-boundary change → OpenAPI snapshots unaffected (
make openapi/ codegen not run, per ticket).
One-sentence recall: the broker now has read_document_markdown — a credential-bearing, RLS-gated read of derived/document.md that returns the markdown inline under 32 KiB or an S3 pointer above it, treats a missing object as the empty contract and a wrong-firm caller as no-access — so Blake's document_markdown input is populated by the broker while Blake itself never touches S3.