PDF Form Onboarding

How a form becomes something the platform can read and write.
Draft 2026-07-20
Updated 2026-09-24
Take every PDF a tenant hands us and turn it into a form the platform can both write  and  read.
FoundationWhat a PDF form actually is

Two flavours of PDF matter.

Flat PDF

Call Sheet — All locations
Date/Time:
Caller Name:
Decedent Name:
Weight:

Just ink on a page. No software fields.

Filling one means printing it and writing on it by hand.

Fillable PDF (AcroForm)

Call Sheet — All locations
Date/Time:
call_datetime
Caller Name:
caller_name
Decedent Name:
Text14
Weight:
Text15

Same visual layout, but with invisible named boxes overlaid on the page.

Named boxes are what let us read and write programmatically.

OverviewThe pipeline, end to end

Ten stages. Two manual passes.

1–5Anchor against paper
1
Intake
→
2
Harvest
→
3
Bootstrap
→
4 · manual
Visual binding
→
5 · manual
Paper sweep + new fields
6–7Anchor against the database
6
DB mapping
→
7
Canonical synthesis
8–10Make it operational
8 · optional
AcroForm rename
→
9
Fixture + blank
→
10
Check · try · go live
Automated
Manual pass
Duplicate detection
In prod Everything else is automated or semi-automated. The only manual prod step is uploading the blank.
Stage 1Intake

Every PDF gets a stable identifier.

The tenant sends us their entire form corpus — email dump, Drive folder, SharePoint export.

We drop every PDF into one working directory. Each form gets a stable form_key; unresolved files park in _unmatched/ until a human decides what they are.

Output Every PDF resolved to exactly one form_key.
Call Sheet - All locations 03.23.pdf
→ kearney_first_call_sheet
Cremation Authorization Form.pdf
→ kearney_cremation_authorization_maple_ridge
BC-VS Death Registration.pdf
→ bc_vs_death_registration
?
Scan_089.pdf
→ _unmatched/ · needs human review
Stage 2Harvest

A script cracks open every PDF and lists its fields.

  • Flat — no widgets at all. Will need to be re-authored later.
  • Semantic — widget names describe themselves. Quick to onboard.
  • Opaque heavy — widget names like Text14. Needs a manual visual pass.
Output One JSON inventory per form, plus a triage summary.
acroform-inventories / kearney_first_call_sheet.json
"form_key": "kearney_first_call_sheet", "pdf_path": "Call Sheet - All locations 03.23.pdf", "triage": "opaque_heavy", "widget_count": 33, "widgets": [ { "name": "Text14", "type": "text", "page": 1, "rect": [104, 712, 380, 728] }, { "name": "Text15", "type": "text", "page": 1, "rect": [104, 690, 380, 706] }, { "name": "Text16", "type": "text", ... }, // ... 30 more widgets, mostly named Text17..Text46 { "name": "chk_coroner", "type": "checkbox", ... }, { "name": "chk_pre_need", "type": "checkbox", ... } ]
→ sorted into opaque_heavy — most widgets carry generic Text14-style names, so this form is heading for a manual visual pass.
Stage 3Bootstrap

Every form gets a registry file.

For each form, we generate a Markdown file — forms/<form_key>.md — that becomes the source of truth for that form's field structure.

The bootstrap seeds it from the catalogue. Human review comes at the next stage, once we can anchor the file against the paper.

Output Per-form registry file, ready for human review.
forms / kearney_first_call_sheet.md
---
form_key: kearney_first_call_sheet
display_name: Call Sheet — All locations 03.23
brand: [kearney_funeral_services]
status: catalogued
---
> Kearney first-call information sheet — initial intake when a death is reported.
## Fields
⚠ DRAFT — seeded from forms-catalog.json inputs_summary, NOT verified against the PDF.
| field_key | label | data_type |
| call_datetime | Date/Time of Call | datetime |
| received_by | Received By | text |
| caller_name | Caller Name | text |
| decedent_legal_name | Decedent Legal… | text |
| decedent_weight | Weight | number |
| … (28 more rows) |
## DB Mapping
_populated by map-form-fields.md — not yet run_
## Module Mapping
_populated by map-form-fields-to-modules.md — not yet run_
Stage 4Visual binding

What does widget #14 actually mean?

For every widget whose meaning isn't obvious from its name, we render the PDF with widget outlines drawn on top.

A human writes down what each opaque widget actually captures — in a bindings CSV that pairs the widget's PDF-internal name with a proposed field key.

Manual pass · Load-bearing
Call Sheet — All locations · Page 1
rendered with widget outlines
1
Date/Time of Call:
Text14
2
Received By:
Text15
3
Caller Name:
Text16
4
Decedent Legal Name:
Text17
5
Weight:
Text18
6
Coroner?
chk_a
bindings / kearney_first_call_sheet.csv
#widget_nameproposed_field_key
1Text14call_datetime
2Text15received_by
3Text16caller_name
4Text17decedent_legal_name
5Text18decedent_weight
6chk_acoroner_case
Stage 5Paper-only sweep & new-field authoring

Some capture areas have no widget behind them.

  • Document as paper-only — runtime fill leaves it blank; a human completes it on paper.
  • Add the widget ourselves in Acrobat, named in snake_case. Save with a - RQxx suffix in the filename.

Why the suffix? The original blank exemplar stays untouched. Every RequiemOS-added field can be told apart from tenant-authored fields at a glance.

Manual pass
Before — Call Sheet as-received
Executor:
Text23
MAID?
← no widget behind this line
Onsite Contact:
Text24
↓ Add widget in Acrobat, save with - RQ01 suffix ↓
After — Call Sheet - All locations 03.23 - RQ01.pdf
Executor:
Text23
MAID?
maid_flag · added
Onsite Contact:
Text24
Stage 6DB mapping

Where does each field live in our database?

  • Existing column — case_deceased.legal_name.
  • Proposed new column — added to the migration backlog.
  • Structured envelope — a JSONB blob for a whole subdomain.
  • Generic key-value — form_submissions.data, for one-offs.
Output A field with a DB destination is a field the platform can round-trip.
First-call sheet field
Decedent Legal Name
decedent_legal_name
→
Database destination
case_deceased.legal_name
EXISTING COLUMN
First-call sheet field
MAID?
maid_flag
→
Database destination
case_deceased.maid_flag
PROPOSED NEW COLUMN
Every field on every form → one of four destinations.
Stage 7Canonical synthesis · Duplicate detection

Which fields are asking the same question?

Before — same concept, four shapes across the corpus
kearney_first_call_sheet Decedent Legal Name
bc_vs_death_registration Legal Surname of Deceased
Given Names of Deceased
eo_cremation_authorization Name of Deceased
kearney_pre_need_funeral_service_agreement Full Legal Name
→
After — one canonical key
deceased.legal_name

The output of Stage 7 is a canonical field dictionary — a single vocabulary of a few hundred keys spanning the entire corpus. Every field on every form is a pointer into that dictionary.

Stage 8Optional AcroForm rename

Sometimes we rename the widgets themselves.

Tenant-owned forms (a funeral home's own paperwork) can have their AcroForm widgets renamed in the PDF itself. Text14 becomes decedent_legal_name. Simplifies runtime fill; makes future onboarding trivial.

Third-party mandated forms (provincial vital stats, CPP/OAS, insurance) are never renamed. The issuer owns them. We live with the original names and let the bindings CSV bridge.

Before — Kearney first-call sheet
Date/Time of Call:
Text14
Decedent Legal Name:
Text17
Weight:
Text18
↓ Rename widgets in the PDF ↓
After — canonical widget names in the PDF
Date/Time of Call:
call_datetime
Decedent Legal Name:
decedent_legal_name
Weight:
decedent_weight
Stage 9The fixture and the blank

Two things make a mapped form operational.

  • The fixture — a YAML file in app-core: which tenant owns the form, which branches offer it, its version:, and a merge_fields entry per widget. Lives in git; changes by pull request.
  • The blank — the PDF itself (with any RQ-added or renamed widgets), stored in S3 at seed_data/blank_pdfs/. The fixture's blank_pdf: names the file — the name is the location.
  • Every deploy registers each fixture: the blank is copied to the tenant's template path and a document_templates row stores the pointer, the widget inventory, and the mapping.
Rule Generation fills only the registered copy. Registration goes by version — nothing changes until version: is bumped.
db/seeds/document_templates/kearney_first_call_sheet.yml
template_code: FIRST_CALL_SHEET tenant: kearney version: 3 blank_pdf: kearney_first_call_sheet.pdf # name = location merge_fields: - acroform_field: "Date of Call" path: first_call_at - acroform_field: "Deceased Name" path: person.first_name transform: concat_name # … one entry per mapped widget
S3 · every deploy registers the fixture
seed_data/blank_pdfs/
  └─ kearney_first_call_sheet.pdf  ← the blank
      ↓ copied on registration
tenants/<tenant_id>/
  ├─ document_templates/<template_id>/
  │   └─ v3.pdf  ← the only copy generation fills
  └─ case_documents/…  ← filled per case
Plus a document_templates row: S3 pointer · widget inventory · mapping. Blank templates and case outputs never share storage.
Stage 10Check, try, and go live

Trial and error, without a deploy per attempt.

  • Check — rails templates:check[CODE] or the form's page in the platform console (/platform/forms) dry-runs registration and names the table.column behind every widget.
  • Try — debug fill labels every box; test fill renders against a real case, lower environments only.
  • Offer — a form reaches the Add Document(s) library once bound in Documents::Templates::ArtifactBindings, in the same PR as the fixture.
Manual in prod · upload the blank
$ rails templates:check[FIRST_CALL_SHEET]
✓ every mapped widget exists in the blank
✓ every path resolves to a real column · every transform exists
✓ branches and price-list groups line up
Date of Call→cases.first_call_at
Deceased Name→people.first_name … (concat_name)
DoB→people.date_of_birth
· 78 widgets left blank  ·  fixture edited without a version bump is flagged
Debug fill — every box labelled
Date/Time of Call:
Text14 · cases.first_call_at
Received By:
Text15 · unmapped
Lower environments · one shared bucket
local · sandboxes · stage
Prove the mapping: check, debug fill, test fill.
→
Prod · its own bucket
Upload the blank from the console
Refuses a PDF the fixture doesn't fit; then registers and binds. Until then the form simply isn't offered.
End stateWhat we get

For every onboarded form.

Write

Fill

The platform produces a completed copy from live case data. Every field populates from case_deceased, case_family_members, custody_events, and so on. No re-keying.

Read

Ingest

The platform accepts a filled version — typed or scanned — and lands the values back into the database, tied to the right case.

Coherence

One source of truth

Every field ties back to a canonical key. Adding a new form doesn't add a new definition of "deceased legal name"; it adds a mapping to an existing one.

Bidirectional forms.
One canonical vocabulary.
Paper and platform, coexisting.
Read more · docs/education/pdf-form-onboarding-explainer.md
← → navigate  ·  P print