RequiemOS needs to marry blank AcroForm PDFs with data from our database — and read pre-filled AcroForms back into it. Stirling-PDF, self-hosted next to the Rails app, is the engine behind both directions.
Kearney's operational reality runs on AcroForm PDFs — a hundred-plus fillable templates that staff print, hand-fill, then re-key into a system after the fact. RequiemOS bridges paper and digital in both directions: fill a blank template from database data, and read a pre-filled AcroForm's values back into the right records. The engine that does the byte-level PDF work is external to Rails; ADR-033 D3 picks it, and this doc is how it fits.
Marry a catalogued blank AcroForm with data from the database, then print, email, download, or hand off to DocuSign for signing.
Take a pre-filled AcroForm (electronic pre-fill or Acrobat-typed), extract every field's value, and propose writes into the case through the owning aggregate services.
Overlay a per-document QR code, flatten the AcroForm into immutable page content, and hand the flat PDF to DocuSign for signing tab placement.
Stirling-PDF is an open-source, Java-based document toolkit that exposes every operation as a REST endpoint. We run one container next to the Rails app on Railway; Rails calls it over the private network for every fill, flatten, stamp, and read. It has no state we care about — every request is stand-alone, the AcroForm and the field values go in, a PDF or a JSON inventory comes out.
Stirling does the byte-level PDF work. RequiemOS owns the meaning — which canonical key a field carries, when to write, whether the value is confirmed. The engine is deliberately dumb about our domain, and that is the point: swap it out later without touching a canonical key or a merge field.ADR-033 · D3 — engine choice
Operators reach Stirling's admin UI through Cloudflare Zero Trust (SSO-gated). Rails reaches Stirling's API over Railway's private network — never through Cloudflare, never over the public internet. Same container serves both; access control is the only thing that differs by path.
Rails never calls Stirling through Cloudflare. STIRLING_BASE_URL resolves to the private DNS name; the public hostname exists only for operator debugging. Wiring Rails at the Cloudflare URL would add latency and — worse — hit an Access challenge that the Rails process can't answer.
Order matters. The QR payload encodes case_document_id, so the row commits before the stamp step. Flatten happens before DocuSign sees the file, so signed values can't be edited between fill and sign. This is the D6 pipeline; if you know these five in order, you know the runtime shape.
Before we touch any bytes, Rails commits the row that the produced PDF will be tied to. The case_document_id becomes part of the QR payload in step 3, so this cannot be later.
POST /api/v1/form/fill with the blank template PDF and a {acroform_field: value} map. Rails resolves canonical keys to AcroForm field names via the template's merge_fields config, applies any bidirectional transform (e.g. concat_name), and passes the flat map in. Stirling returns the filled PDF.
If the template's carries_qr_code is true, Rails generates a QR PNG (rqrcode) encoding the deep-link URL and calls POST /api/v1/misc/add-stamp or /misc/add-image to composite it at the template's configured coordinates. Family-facing keepsakes and government forms set carries_qr_code = false and skip this step.
POST /api/v1/misc/flatten with flattenOnlyForms=false. Data fields and the QR overlay become immutable page pixels; the AcroForm dictionary is removed entirely. Signature-widget positions were captured at template registration into signer_roles — they don't need to survive flatten because DocuSign places its own signature tabs at those coordinates on the flat PDF.
The flat PDF lands in S3 under the Storage:: namespace and is recorded on case_documents.pdf_storage_url. If requires_signature = true, the file enters the Documents & Signing PRD §5.3 flow: DocuSign envelope, signer tabs at the pre-captured signer_roles coordinates, signed PDF returned and stored. Otherwise the flat file is the final artifact — printable, emailable, downloadable.
The case_document is now a permanent record — flat, QR-tagged (where applicable), audit-linked, and if signature-bearing, cryptographically signed by DocuSign. Reprint or resend never regenerates; the stored PDF is authoritative.
The read side is the same engine, running in reverse. Stirling extracts field values; Rails resolves them through canonical keys and transforms; a director confirms; the aggregate services do the writing. Nothing bypasses aggregate boundaries — this is the D5 discipline that ADR-034 will formalise for the pre-need slice.
Someone uploads a PDF that was filled electronically — pre-populated by an upstream system, or typed in Acrobat. This is D5a: AcroForm-field extraction, no OCR. Handwritten intake (D5b) is Phase 2+ with its own engine decision.
POST /api/v1/form/fields returns the exact {field_name: value} map for the pre-flatten AcroForm — verified byte-identical round-trip on all 86 fields of the reference form, including accents and checkbox states.
The template's merge_fields config binds each AcroForm field to a canonical key (deceased.legal_name). Derived keys with bidirectional transforms (e.g. concat_name with a from_form parser) decompose composite fields into the underlying leaf values. Ambiguous parses surface as candidate sets — never auto-resolved.
The extracted values render in a review UI as proposals against the case (or Person, for pre-need). The director accepts, edits, or rejects each one. Ambiguous concat_name parses (e.g. "Mary Anne Van Der Berg") surface as candidate sets for explicit disambiguation. Same posture as at-need conversion (ADR-016 Rule 7).
Confirmed values route through the owning aggregate services: People::IdentityUpdater for identity facts, Cases::… for case-scoped, Contracts::… for contract-scoped. Never a direct write from the ingest path to a column — ADR-016 Rules 1 & 2 apply here exactly as they do to any other write.
The 2026-07-12 verification (LAN box, Stirling 2.14.0, 86-field real form end-to-end) proved Stirling can do everything ADR-033 needs. It also surfaced three behaviours that will silently bite Documents::PdfFormFiller if the implementation isn't defensive about them. These aren't bugs — they're contract details that come out of Stirling being deliberately dumb about our domain.
form/fields-with-coordinates returns widget y in a top-left origin. misc/add-image and misc/add-stamp place their y in a bottom-left origin. Overlay a QR or a DocuSign tab at a captured widget position without converting, and the marker lands at the top of the page instead of on the signature line.
What looks semantically like a radio group (four "location" checkboxes, one for each brand) is four independent Yes/Off checkboxes at the PDF layer, with no radio-group binding. Nothing stops Stirling from setting all four to Yes if the caller passes that map. The single-select intent lives only in our registry-layer bindings, not in the AcroForm.
POST /api/v1/form/fill given a field key that doesn't exist on the template returns 200 with a valid PDF — the real fields fill, the phantom key is dropped, no warning. A template whose fields were renamed under us would "fill successfully" while silently losing every renamed value's data.
Stirling is deliberately domain-blind. It fills bytes, stamps images, and flattens dictionaries — nothing about deceased identity, contracts, or aggregates enters its process. Every piece of meaning lives in Rails. This is the boundary that keeps engine choice a one-file swap.
The verification ran against a disposable LAN box. The going-forward instance is on Railway, version-pinned by digest, behind Cloudflare Zero Trust on the operator side and reachable only over Railway's private network from Rails. This is the ADR-033 §7.2 posture, realised in the deployment plan.
pdf.mvp.requiemos.com · SSO gated
A single Cloudflare Access application covers /* on the public hostname. Authorised operator emails (Philip, Zareef, Matt) get a 24-hour SSO session; one-time-PIN by email is the default IdP. Everything the operator's browser fetches — page loads and same-origin /api/** calls — carries the SSO cookie, so the UI works transparently.
The trap to avoid: a policy that blocks /api/* at the edge would kill the UI, because the UI's own JavaScript calls /api/** against the same origin. One policy for the whole path is correct.
stirling-pdf.railway.internal:8080 · X-API-KEY
Documents::PdfFormFiller resolves its base URL to Railway's internal DNS name, which never leaves the private WireGuard mesh. Every request carries X-API-KEY (a shared secret generated by Railway's ${{secret(64)}} template function and referenced from Rails, so rotations don't drift).
The trap to avoid: pointing Rails at the Cloudflare hostname. It would add latency and hit an Access challenge the Rails process can't answer. Private URL only.
Behind those two paths, defence-in-depth stays on: Stirling's own SECURITY_ENABLELOGIN gates the UI at the origin, X-API-KEY gates every API call, endpoint hygiene disables tools we don't use, and the JVM heap is capped so a runaway restart is graceful.
Stirling being verified isn't the finish line — it's the point where several previously-blocked work streams start. ADR-033 D3 is committed; the fallback path (HexaPDF commercial, then pdftk-java) is not invoked; the Documents & Signing PRD's PDF_FORM branch is ready to build against a real engine.
The ADR-033 fill service now has a real backing engine and a real endpoint contract. Every method it exposes maps directly to a verified Stirling endpoint.
The four signing tables (document_templates, case_documents, signature_requests, signature_events) land in that arc. D1's schema implications for PDF_FORM templates are ready to migrate.
The D5a read side (extract field values from a pre-filled AcroForm) is validated by the same verification run — 86/86 fields round-trip byte-identical. ADR-034 rebases its parse engine onto Stirling.
The canonical template pilot pair (cremation authorisation + control of disposition) gates G3/G4 in the verification plan. With A–D + G1/G2 green, Chunk 8 is the next real step for real forms.