Architecture ADR-033 · D3 / D6 / D7 Overview
v1.0 · 2026-07-12

Filling & reading forms with Stirling-PDF.

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.

Audience — Zareef (deploy & wire) · Matt (design against the fill / ingest surface)

Paper doesn't disappear on day one.

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.

01

Fill from data

Marry a catalogued blank AcroForm with data from the database, then print, email, download, or hand off to DocuSign for signing.

02

Read into data

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.

03

Stamp & flatten

Overlay a per-document QR code, flatten the AcroForm into immutable page content, and hand the flat PDF to DocuSign for signing tab placement.

A single HTTP service that speaks PDF.

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.

Version pinned
2.14.0
Verified 2026-07-12; re-verify on any 2.x → 2.y bump before promoting.
Licence
MIT-core
Every endpoint we call is outside Stirling's proprietary directories. No copyleft.
Fee model
Self-hosted
Zero per-document cost. One Railway container; no external SaaS on the fill path.
Interface
REST · JSON
Rails calls via Documents::PdfFormFiller. Engine choice is a one-file swap.
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

Two paths in, one engine.

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.

OPERATORS · PHILIP / ZAREEF / MATT Browser (incognito) pdf.mvp.requiemos.com Cloudflare Zero Trust Access policy /* SSO · 24h session RUNTIME · REQUIEMOS RAILS app-core (Rails) Documents::PdfFormFiller Railway private network *.railway.internal X-API-KEY · never public Stirling-PDF 2.14.0 One container · Port 8080 Admin UI · /api/** REST form · misc · analysis · security defence in depth: origin login + X-API-KEY SSO cookie X-API-KEY UI: operator debugging / hand ops API: every fill / read / flatten / stamp
Two paths, one origin — access control is the only thing that differs

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.

One case_document, five ordered steps.

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.

1
Rails · Persist

Insert the case_documents row

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.

2
Stirling · Fill

Fill the AcroForm fields

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.

Multi-page, non-ASCII, checkboxes: all validated in the 2026-07-12 verification. 86 fields on the first-call sheet filled in ~0.2s. Accents (Marie-Ève) survive byte-exact.
3
Stirling · Stamp

Overlay the QR (per template config)

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.

Payload: {base_url}/cases/{case_id}/documents/{case_document_id}. Scans by authenticated staff resolve straight to the document record; unauthenticated scans land on a login screen.
4
Stirling · Flatten

Flatten fields & QR into page content

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.

Why full flatten and not forms-only: full flatten rasterises the page and is the only mode that yields a truly AcroForm-free document. Legal-integrity requirement — a signed PDF must not have editable fields.
5
Rails · Store & hand off

Upload to S3, hand to DocuSign if signing

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.

Produced

An immutable, identified, optionally-signed PDF

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.

Confirm-before-write, never direct-write.

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.

1
Operator · Upload

A pre-filled AcroForm arrives

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.

2
Stirling · Read

Extract every field's value

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.

Read the pre-flatten AcroForm, not a flattened copy. Full flatten rasterises the page; the field values become pixels. Ingest must operate on the AcroForm that still has an intact field dictionary.
3
Rails · Resolve

Map AcroForm names to canonical keys

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.

4
Operator · Confirm

Director confirms every proposed write

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).

This is Matt's design surface. Upload flow, per-field accept/reject/edit, candidate-set resolution UX. The product surface lives in a supplemental PRD (deferred until D5 is scoped for build).
5
Rails · Write

Persist through aggregate services

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.

Three things the fill service must honour.

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.

RN-7 · Coordinate y-origin flip

Read and overlay endpoints use opposite y-origins.

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.

Rule for the fill service: y_overlay = page_height − widget_y − widget_height. Applied once, in the coordinate translation helper, so no caller ever passes an unflipped y.
RN-3 · Pick-one groups are independent checkboxes

Mutual exclusivity is not enforced by the PDF.

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.

Rule for the fill service: for a pick-one canonical field, set exactly one AcroForm checkbox to Yes and explicitly set every sibling in the group to Off. Never rely on the PDF to enforce the invariant.
RN-8 · Unknown field keys are silently ignored

A renamed or missing field returns 200 with the value dropped.

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.

Rule for the fill service: read the field inventory (/form/fields) first — or cache it on document_templates.acroform_field_inventory — and validate every merge_field.acroform_field against it before calling fill. Treat any unknown key as a hard error at our layer.

What Stirling does, what Rails must do.

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.

Stirling · Bytes

  • Read an AcroForm's field inventory — names, widget types, coordinates, multiline flags.
  • Fill AcroForm fields from a flat {name: value} map.
  • Overlay an image (QR, marker) at absolute coordinates or in a 3×3 grid position.
  • Flatten fields and overlays into immutable page content — full or forms-only.
  • Enforce X-API-KEY on every endpoint at the origin.
  • Return structured error JSON for malformed inputs (corrupt PDFs, missing AcroForm).
  • Serve the ops UI to authorised operators — separate from the API contract Rails uses.

Rails · Meaning

  • Resolve canonical keys to AcroForm field names via merge_fields.
  • Run bidirectional transforms (concat_name, compute_age_at_death) on both fill and ingest.
  • Validate every acroform_field against the read inventory before every fill (RN-8).
  • Enforce single-select exclusivity in-code for pick-one checkbox groups (RN-3).
  • Flip y coordinates between read and overlay (RN-7).
  • Capture signer_roles coordinates at template registration; feed them to DocuSign at fill time.
  • Route every ingest write through the owning-aggregate service — never direct-column.
  • Surface ambiguous parses as candidate sets for director confirmation.

One container. Two access paths.

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.

Operator path

Cloudflare Access → Stirling UI

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.

Rails path

Private network → Stirling API

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.

Everything downstream of the fill engine question.

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.

Documents::PdfFormFiller can be built

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 GPL & Contracts arc unblocks

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.

ADR-034 pre-need ingest gets a proven read path

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.

Forms reconstitution can move to Chunk 8

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.