ADR-019 DD-2ADR-033 D6Walkthrough
Draft · 2026-09-30 · based on DocuSeal spike run 1

Signing a form, end to end

How a case document would travel from a blank Kearney AcroForm to a signed, stored PDF, with Stirling filling it and DocuSeal collecting signatures. Every timing on this page was measured in the spike, not guessed.

For Zareef · how signing would work before any of it is built. This is a proposal: no ESignature:: code exists yet.

Four numbers to keep in mind

≈1.7 s
Click “Send for signature” → signing links ready (one document; ≈2.8 s for a 3-document case packet)
≈1–2 s
Last signature → submission.completed webhook arrives
0.5 MB
Signed 4-page PDF with the forms-only flatten (vs 6.5 MB raster today); text stays searchable
$0.20
DocuSeal fee per completed submission; a whole case packet counts once (plus a Pro seat)

Who does what, in order

Five actors. Read top to bottom. Each box is a step; the lane shows who performs it. Steps are numbered to match the detail below.

RequiemOS (app-core + React) Stirling-PDF DocuSeal People (FD, family, witness) S3 storage
Phase
RequiemOS
Stirling
DocuSeal
People
S3
Phase 0: once per form version (template onboarding)
SetupCapture boxes
0aRegistrar calls Stirling on the blank; maps signature widgets to signer roles by name
0bform/fields-with-coordinates returns widget rects (top-left, points)
0cOnboarder confirms role map (and captions for forms with no widgets)
0dRects saved in document_templates.signer_roles
Phase 1: send (inside one request)
TriggerSend
1Choose signers and roles for this case (incl. conditional sections)
★FD clicks Send for signature
2Fetch blank PDF ~0.1–0.3 s (est.)
ProduceFill + flatten
3Send merge data (validated keys)
4form/fill ~0.16 s
5misc/flatten forms-only ~0.14 s
6Store produced PDF (as today)
Hand-offCreate submission
7Build fields: rect ÷ page size, 1-based page, no y-flip; add date fields
8POST /submissions/pdf returns submitter ids + signing links ~0.8–1.2 s
RecordSave ids
9Save submission id, submitter ids, links. Document → PENDING_SIGNATURE. Response to FD.
10submission.created webhook < 1 s
Phase 2: signing (human time; minutes to days)
RemoteEmail link
11aEmails each signer their link (or we send it ourselves)
12aFamily opens the link on their own device, reviews, signs
In personTablet
11bReact page embeds <docuseal-form> (inline, not an iframe)
12bFamily signs on the arrangement-room iPad ~14 s (measured)
ProgressPer signer
14Update signer status on the case (idempotent)
13form.viewed → form.started → form.completed (with IP + device)
↺Next signer (e.g. FD witness)
Phase 3: completion
SignalAll signed
16Verify HMAC, dedupe, record event, then confirm with GET /submissions/:id
15Seals PDF (certificate + timestamp), builds audit log, sends submission.completed ~1–2 s
StoreSigned copy
17Download signed PDF + audit log ~1 s
18tenants/<tid>/signed_documents/… via Storage::
CloseCase updates
19Document → SIGNED; artifact lifecycle signed satisfies the readiness requirement (ADR-061)
✓FD sees “Signed”; cremation can be scheduled
Running alongside: a reconciliation poll (GET /submissions/:id for anything still pending) catches lost or late webhooks. See step 16 and the gotchas.

What happens at each step, and what to build

Times are from the spike: LAN Stirling to DocuSeal Cloud (US), from Philip's PC. On Railway expect small network differences; a self-hosted DocuSeal next to app-core would make steps 8 and 17 faster.

0

Onboarding: capture the signature boxes once per form version

When a template is registered, call Stirling form/fields-with-coordinates on the blank. Keep the widgets typed signature (plus initials/date widgets) and map each to a signer role by its name. Kearney's names already carry the role, e.g. disposition_authorizer_signature. Store the rects on signer_roles, which today holds role-name strings only, so this is a schema change.

  • Conditional forms: Control of Disposition's signature lines belong to either Section A (no will) or Section B (will). Store both, and pick per case at step 1.
  • Forms with no signature widgets (e.g. EO Cremation Authorization, government forms): locate boxes by exact-case caption search on the blank, then have a person confirm. A case-insensitive search put a box on body text in the spike.
onceper form version
1

FD clicks “Send for signature”

app-core resolves who signs what: authorizer, purchaser, FD witness, and so on. It takes names and emails from the case, plus the section choice for conditional forms. Several documents for the same case can go in one submission, giving one packet, one audit log and one $0.20 fee.

—user action
2–6

Fill and flatten with Stirling (already built, one setting changes)

This is the shipped Documents::Generator pipeline: S3 blank → form/fill → QR stamp if the template carries one → misc/flatten. The proposed change is to call the flatten with flattenOnlyForms=true (forms-only) for documents that go to signing.

  • Why: the signed output is text-searchable and about 10× smaller, and sends about 4× faster. Fidelity is identical, and nothing stays editable.
  • Field count doesn't matter: filling 145 contract fields took the same ~0.1–0.2 s as filling 23.
  • Raster flatten scales with pages: ~0.6–0.9 s per page, which is why it's slow.
≈0.3 sfill + flatten
7

Turn stored rects into DocuSeal fields

For each role's rect: x = rect.x / page_width, y = rect.y / page_height (same for w, h), page = pageIndex + 1. Both Stirling and DocuSeal use a top-left origin, so there is no y-flip. The flip is only for Stirling's own stamp endpoints, like the QR code.

# one DocuSeal field per signer box
{ "name": "authorized_person_signature", "type": "signature",
  "role": "Authorized Person", "required": true,
  "areas": [{ "x": 0.5319, "y": 0.6540, "w": 0.3791, "h": 0.0228, "page": 2 }] }

# signed-date boxes: filled automatically at signing, never by us
{ "name": "signed_day", "type": "date", "role": "Purchaser", "readonly": true,
  "default_value": "{{date}}", "preferences": { "format": "DD" }, "areas": [ … ] }
≈0.1 sbuild request
8–10

Create the submission at DocuSeal

POST /submissions/pdf with the produced PDF (base64), the fields, and one submitter per role. Put our signature-request id in each submitter's external_id. The response returns each submitter's id and signing link (embed_src). Save them, and mark the document PENDING_SIGNATURE. DocuSeal then fires submission.created.

  • send_email: true lets DocuSeal email the links. Alternatively we send our own branded email or SMS with the link.
  • This is quick enough to do synchronously inside the Send request. app-core has no job queue. A raster packet (≈12–14 s) would not be.
≈1 sper document
11–14

People sign: remotely, or on the tablet in the arrangement room

Remote: the family opens the link on their own phone or computer. In person: our React page mounts DocuSeal's <docuseal-form> web component with the signer's link. It renders inline, not in an iframe, and works on an iPad (verified: ~14 s to initial twice and sign). As each signer finishes, DocuSeal sends form.completed with their IP and device, and we update that signer's status.

humanseconds to days
15–16

Completion: trust the webhook, but verify it

When the last signer finishes, DocuSeal seals the PDF with a digital signature and timestamp, builds an audit log (document hashes, IPs, devices), and sends submission.completed about 1–2 s later. Our endpoint:

  • checks X-Docuseal-Signature = hex HMAC-SHA256 of "<timestamp>.<raw body>" with the webhook's whsec_… secret (reject if older than 5 min);
  • stores the raw event and ignores duplicates;
  • confirms state with GET /submissions/:id before acting.
≈1–2 safter last signature
17–19

Store the signed copy and move the case forward

Download the signed PDF and the audit-log PDF from the URLs in the submission and write them to S3 via Storage:: (ADR-007). Then set the document to SIGNED and drive the artifact lifecycle to signed, which satisfies the case's readiness requirement (e.g. cremation_authorization_signed, ADR-061).

≈1–2 sdownload + store

Exceptions and how each surfaces

SituationWhat DocuSeal doesWhat app-core should do
Signer declinesform.declined with data.decline_reason. Decline is only possible in the signing UI.Mark the request declined, notify the FD, offer void and re-send.
Nobody signs in timesubmission.expired at expire_at (fired on the second in the spike).Mark expired; FD can re-send.
Our endpoint is downRetries with doubling delays: ≈1, 2, 4, 8 min … (about 2.8 days in total).Handler must be idempotent. The poll catches anything missed.
Events out of ordersubmission.completed can arrive before the last form.completed.Never depend on order. Re-read the submission.
Signer tries to change prefilled dataNothing to change: the PDF is flattened. With DocuSeal-side prefill, writes to another role's field are dropped.Nothing extra.

Things the spike taught us

Gotchas

  • Signing order isn't enforced by DocuSeal when we complete signers through the API. If order matters (e.g. witness after authorizer), enforce it in app-core.
  • Dates default to US format (09/30/2026). Always set preferences.format.
  • A read-only date field without {{date}} prints blank. Whether {{date}} means the creation or the signing date is still being tested.
  • Test and live are separate worlds. Different API keys, webhooks and HMAC secrets. Test output is stamped “NOT LEGALLY BINDING”. The HMAC secret is only visible in the console (Webhooks → Security → HMAC).
  • Don't pass fields expecting DocuSeal to merge them with auto-detected ones. It replaces them.

What worked cleanly

  • Stirling's rects map straight onto DocuSeal boxes: exact on every page, first try.
  • Multi-role signing including the FD witness; multi-document packets in one submission.
  • Acrobat shows DocuSeal Cloud's signature as valid (Adobe trust list, timestamped, long-term validation). Self-hosted can use our own certificate.
  • Inline tablet signing on an iPad with no iframe.
  • Polling alone is enough to get the signed PDF if webhooks fail.

Where DocuSeal stops and RequiemOS starts

Only steps 8, 10, 13, 15 and 17 talk to DocuSeal. Everything else (who signs, where boxes go, the flattened PDF, storage, case state) is ours. Keeping that line in ESignature:: (ADR-019) means a second provider for a tenant that insists on DocuSign would be an adapter, not a rewrite.

Provider-neutral (ours)

  • Signer roles and signature rects
  • Stirling fill + forms-only flatten
  • Signature requests/events tables
  • S3 storage, case and readiness state
  • Webhook endpoint shell, polling job, idempotency

Adapter (per provider)

  • Create submission / envelope
  • Rect → provider coordinates (fractions for DocuSeal, points for DocuSign)
  • Signing-link / embedded view
  • Webhook signature check and event mapping
  • Fetch signed PDF + audit certificate

Per tenant (config)

  • Which provider
  • Credentials (shared DocuSeal key, or a tenant's DocuSign account)
  • Webhook secret per provider/environment
  • Certificate (Requiemos default; custom as a paid add-on)

Open questions

  • Adopt the forms-only flatten for signing documents? This is a proposed ADR-033 D6 amendment.
  • Self-hosted or Cloud DocuSeal? It turns on Canadian data residency; Cloud is hosted in the US or EU.
  • Vendor answers pending: open-source API licensing, how a packet is billed, per-tenant certificate settings, and whether the “Signed with DocuSeal.com” reason can be rebranded.
  • Schema: signer_roles gains rects, and the signing tables (signature_requests, signature_events) don't exist yet.