Story 1867: the hardened render stage — a DOCX/PPTX becomes an immutable, page-numbered PDF that citations anchor to, without ever running a macro, opening unscanned bytes, or letting a zip-bomb reach LibreOffice

Why this exists (the catastrophe it prevents). Counsel uploads a 60-page Word affidavit. Today the extraction worker is PDF/image-only, so a .docx dead-ends at unreadable: no pages, no text, no citations — invisible to search, to Blake, to contradiction analysis. The obvious fix ("just run LibreOffice on it") is a security minefield. A Word document can carry a macro that executes on open — arbitrary code inside our VPC. An OOXML file is a ZIP, so a hand-crafted Office zip-bomb inflates to gigabytes inside the converter and OOMs the container. And if rendering runs before the malware scan, we have just opened unscanned attacker bytes with a 200 MB native binary. The subtler trap is citation drift: if the rendered PDF can be silently re-generated with a different LibreOffice build, a re-paginate re-points every landed citation to the wrong page — a strike-off-grade integrity failure.

The fix is one new SQS-triggered container-image Lambda (document_render) that sits between stored and extraction and is hardened on five axes: (1) it consumes only stored, post-malware-scan documents (DR-10) — it never opens unscanned bytes; (2) LibreOffice runs against a throwaway profile with MacroSecurityLevel=3, no network, and an isolated ephemeral /tmp (DR-11) — macros never execute; (3) the OOXML container is re-checked against the substrate zip-bomb caps before LibreOffice opens it (DR-6); (4) the rendered PDF is written once with an immutable render_id + pinned image digest + content SHA-256, never overwritten (DR-1) — citations anchor to bytes that cannot change; (5) every failure mode reaches a terminal — an infra crash retries to the DLQ and parks needs_review, a content failure is unreadable(render_failed) or rejected (DR-5) — so the import ledger always balances.

Satellite view: where this sits in the pipeline

An Office document is a documents.documents row scanned to stored like any upload. The render stage is the universal pre-extraction gate: a renderable Office type is converted to derived/rendered.pdf and then forwarded to extraction; a non-Office type (PDF/image) passes straight through untouched. Extraction (an existing, separate Lambda) then produces pages + bbox as before — now fed a real PDF for Office docs instead of dead-ending.

document (stored)                       THIS WORKER (container Lambda)          downstream
  malware-results: CLEAN  --SQS pointer-->  load doc, gate on stored (DR-10)
                                             |
                                  office? ---+--- no --> forward to extraction (passthrough)
                                             |
                                            yes
                                             |
                                   zip-bomb caps on OOXML (DR-6) --over-cap--> failed (rejected)
                                             |
                                   LibreOffice --headless --convert-to pdf
                                     macros off, no net, isolated /tmp (DR-11)
                                             |
                              +--------------+---------------+
                              |              |               |
                        infra crash    content fail      success
                        (OOM/timeout)   (0 pages)            |
                              |              |        write-once rendered.pdf
                        raise -> retry   unreadable      + render_id/version/sha (DR-1)
                        -> DLQ -> park    (render_failed)      |
                        needs_review                     forward to extraction
                        (render_partial)
Per the repo's "done = system boundary" rule, the security-critical pieces are pulled into pure modules: caps.py (the OOXML zip-bomb gate, bytes-in/verdict-out) and engine.py (the LibreOffice port, with the subprocess implementation behind a RenderEngine protocol). The state machine in processor.py runs behind injected ports (S3 sink + render engine), so the scan-gate / write-once / crash→terminal flow is exercised end-to-end against a real RLS-scoped database with the engine + S3 faked. The container build + ECR + queue/IAM are an infra follow-up — flagged below, not built here.

The decisions that matter

1. Scan-before-render — the stage never opens unscanned bytes (DR-10)

The processor loads the document and returns SKIPPED for anything not in availability='live' + processing_status='stored' — before any S3 fetch. A pending/uploaded document (finalized but not yet malware-cleared) is a no-op the worker re-defers; its bytes are never fetched, let alone handed to LibreOffice. This mirrors the extraction worker's identical gate and is proven by a test asserting the S3 sink's fetch was never called for an uploaded document.

2. Hardened LibreOffice — macros never execute (DR-11)

Each conversion gets a fresh /tmp workspace holding the input, an isolated user profile, and the output, all removed afterward (one document can never see another's bytes on a warm container). Before soffice is invoked, the engine seeds that profile's registrymodifications.xcu with MacroSecurityLevel=3 (very high — no Basic/Python macro runs) plus DisableMacrosExecution=true. The command line is --headless --norestore --invisible --nodefault --nologo --nofirststartwizard --convert-to pdf with -env:UserInstallation pointing at the throwaway profile; the subprocess env is scrubbed (writable HOME under /tmp, no proxy handles), and the container has no egress. The unit test installs a fake subprocess.run that reads the profile the engine seeded and asserts MacroSecurityLevel=3 is present — proving macros are disabled before the document opens, not after.

3. Zip-bomb caps re-applied at the render entry (DR-6)

DOCX/PPTX are ZIPs that skip the dedicated ZIP-expand stage (they are a single document, not a folder), so the substrate §5.5 caps are re-applied here, reusing the same ZipExpandCaps ceilings and RejectReason vocabulary — not a second set of numbers. check_office_zip_bomb walks the archive one entry at a time, with a bounded decompression that aborts the moment a part crosses the per-child or total cap (a lying header that under-declares file_size cannot smuggle a bomb past the cheap ratio precheck). Over-cap → the document is failed with the closed reason on its status-event row, and LibreOffice is never invoked — the test asserts the fake engine's render was never called. A non-ZIP legacy binary (DOC/PPT) or RTF has no compression container and passes straight through.

4. Immutable write-once render — citations can never silently re-point (DR-1)

On a successful render the worker writes derived/rendered.pdf exactly once via an S3 conditional put (IfNoneMatch='*' → a 412 if the object already exists, so a redelivery never overwrites it), computes the content SHA-256, and stamps the render anchor — render_id (UUID), render_version (the pinned LibreOffice image digest), rendered_pdf_key, render_content_sha256 — all in the same transaction. A redelivery short-circuits the moment it sees render_id is already set: it never re-renders and never re-points the anchor. The test renders twice and asserts the second pass is ALREADY_DONE, the engine was not called again, and the render_id + SHA are unchanged. The pinned digest is the DR-1 stability guarantee: a re-paginate with a different LibreOffice build would carry a different render_version, so a landed citation's anchor is provably the bytes it was minted against.

5. Every failure reaches a terminal — the ledger always balances (DR-5)

The failure split mirrors substrate §5.6. An infra fault (OOM / timeout / non-zero exit from the converter) raises RenderInfraError, which propagates out of the processor → the handler reports an SQS batch-item failure → the record retries → on exhaustion it lands in the DLQ, whose consumer (lambda_handler_dlq → mark_render_dlq_terminal) parks the document at needs_review(render_partial). A content fault (LibreOffice ran cleanly but produced no PDF / zero pages) is a terminal unreadable(render_failed), never a retry. An incomplete render (fewer pages than the source's known segment count) is needs_review(render_partial) — never indexed on a partial — while the partial render is still anchored. Because the import ledger was already settled at the stored transition, the document reaching any render terminal keeps the accounted-equals-expected invariant intact (V-LEDGER). The processor is the first writer of the processing_status_reason code column the S1 schema laid down.

6. Observability: one span per boundary, low-cardinality, continuous trace (ADR-124)

Two registry-approved manual span names were added to the closed ADR-124 enum: functions.document_render.batch (one per SQS invocation, links every record's stored trace context) and functions.document_render.process_record (CONSUMER kind, parented to the document's stored traceparent so trace_id stays continuous stored → render → extraction). The per-record span carries only safe attributes — firm_id, matter_id, document.mime_type, document.page_count, and a closed render.outcome vocabulary (rendered/passthrough/rejected/render_failed/render_partial) added to the document-KPI allowlist (structurally excluded from X-Ray annotation promotion). No document content reaches telemetry. The worker has no egress.

Verification

The two security cores are proven directly on bytes; the state machine over a real RLS-scoped DB with the engine + S3 faked; the telemetry contract against the registry.

CheckProves
test_document_render_caps.pya benign DOCX-like archive and a non-ZIP (DOC/RTF/OLE) payload pass; a corrupt ZIP, a ratio bomb, an over-count / over-child-size / over-total archive each → their closed reason code (DR-6 / DP2-08)
test_document_render_engine.pythe seeded profile sets MacroSecurityLevel=3 + DisableMacrosExecution before soffice runs; the hardened headless flags + isolated -env:UserInstallation + scrubbed env are present; a timeout / non-zero exit → RenderInfraError; no-PDF / zero-pages → RenderContentError (DR-11 / DR-5 / DP2-10)
test_document_render_observability.pyboth new span names resolve to CONSUMER kind and enforce firm_id; render.outcome validates through the shared KPI allowlist
test_document_render_worker.py (integration, real DB)an unscanned doc is never opened (DR-10); a non-office doc passes through to extraction; a clean Office render writes rendered.pdf once + stamps the anchor + forwards onward; a redelivery never re-renders or re-points (DR-1 / DP2-02); a zip-bomb is failed with LibreOffice never invoked (DP2-08); a zero-page render is unreadable(render_failed) (DP2-06); a partial render is needs_review(render_partial) yet still anchored (DP2-05); an infra crash propagates and the DLQ leg parks a terminal (DR-5 / DP2-07)

17 new unit tests + 8 new integration tests pass; the full local unit suite (1270) and the touched-area regression (startup-contract, zip-expand, extraction, settings, audit registry/coverage, observability) are green. ruff check is clean on every changed file; git diff --check is clean; alembic heads reports a single head (this Story adds no migration — all RFD-DOC-002 V2 schema landed in S1's 0137). No route, request/response DTO, or trust-boundary HTTP payload changed (the SQS envelope is an internal message), so the OpenAPI snapshots are unchanged. (mypy is local-only and not a CI gate; the new code carries only the same pre-existing SQLModel column-typing noise present throughout the repo.)

Infra follow-up (flagged, not built here). This PR ships the worker, the docker/document-render/Dockerfile, and the tests — the LibreOffice container image needs ECR + a deploy-document-render.yml workflow + the render SQS queue/DLQ/IAM, all pramaan-infra work (the deploy must reference the image by its immutable digest and inject it as DOCUMENT_RENDER_IMAGE_DIGEST so every render stamps the pinned render_version). The producer side — the malware-results stored transition enqueuing Office docs onto the render queue instead of straight to extraction — is a small wiring change that rides that infra landing. And teaching the extraction stage to consume rendered.pdf (so Office docs actually gain pages) is a later RFD-DOC-002 story; this Story's contract ends at producing the immutable artifact + forwarding onward.

One sentence to remember: an Office document becomes an immutable, page-numbered PDF that citations can anchor to forever — rendered only after the malware scan, with macros disabled and zip-bombs rejected before LibreOffice ever opens the file, and every crash or bad render driven to an accounted terminal instead of a silent drop or a DLQ black hole.