Treat an onboarding packet as one durable job with explicit stages, immutable inputs, and a separately retained evidence record. The job may fill templates and merge pages automatically, but it should release a packet for signature only after every expected document, field, page, and signer has passed validation.
That is the operational recommendation. It prevents the awkward failure where a perfectly valid signature covers the wrong revision, and it gives support staff a job ID they can use without reconstructing events from three vendor dashboards. The signature provider is only one dependency; the packet state machine is the system of record.
For scanned B2B SaaS onboarding documents, OCR belongs before assembly and behind a confidence gate. Low-confidence text should send the job to review, not silently become a legal name, tax identifier, or search index entry. I'm not sure any universal confidence threshold is defensible: document classes, scan quality, languages, and the cost of a wrong field vary too much. A labeled evaluation set from your own traffic resolves that uncertainty.
What failure signal should stop an onboarding packet fill, merge, and sign job?
Stop on an invariant violation, not merely on a dependency error. Missing required fields, an unexpected page count, an input hash that changed after approval, a signer mismatch, or an OCR result below the document-specific threshold all mean the artifact is not ready to sign. A timeout is different: it may be safe to retry if the stage is idempotent and the remote result can be reconciled before another side effect is issued.
The nasty case is ambiguity. Imagine the signing request times out after the provider accepted it but before your worker recorded the remote envelope ID. Blindly retrying can create two signature requests for the same employee. Mark the stage reconciling, query by your stable idempotency key, and allow exactly one remote object to advance. If the dependency cannot search on that key, keep a local outbox record and treat an unknown result as manual review. Don't convert uncertainty into duplication.
Stop there.
Capacity planning starts here, too. A packet with 14 source documents is not one unit of work: it can contain 14 downloads, several OCR calls, a merge, a validation pass, storage writes, and signing operations. Forecast each constrained stage separately, bound concurrency per dependency, and retain enough queue headroom to absorb retries without starving new hires. Your mileage may vary because page count and scan quality usually dominate OCR time more than packet count does.
Use an SLO that follows the employee-visible outcome, such as the proportion of valid packets made available for signature within the target window. Worker uptime and queue depth are diagnostics, not the promise. Track correctness beside latency: packets released with missing required pages should have an error budget of zero, while delayed packets can have a small, explicit budget.
Build the job around artifacts, hashes, and state transitions
The durable record should identify the job, employee, template revision, ordered source artifacts, normalized field values, stage attempts, output hashes, and signing evidence. Store large PDF objects outside the transactional row, but commit their content hashes and object versions with state transitions. A log line can be deleted or sampled; an audit record should remain queryable under the retention policy.
PDF is a page-description format with multiple revisions and feature sets, so visual inspection is a weak assembly test. Parsing and validation matter. Define the accepted PDF profile, reject encrypted inputs unless decryption is an intentional stage, normalize page geometry when required, and preserve the exact pre-signature bytes. ISO 32000-2 is the primary specification for PDF 2.0; application teams still need a narrower internal contract because “valid PDF” does not mean “valid onboarding packet.”
This state model keeps the dangerous transitions visible:
| State | Required evidence before entry | Retry rule | Operator action |
|---|---|---|---|
extracting |
Immutable source versions and hashes | Retry per source artifact | Replace an unreadable scan through a new revision |
assembling |
Approved OCR output and validated fields | Rebuild from the same inputs | Inspect field and page validation results |
ready_to_sign |
Final page manifest and pre-sign hash | No mutation after entry | Cancel and create a new packet revision |
signing |
Signer identity, consent context, idempotency key | Reconcile before retry | Resolve ambiguous remote state |
complete |
Signed bytes, post-sign hash, evidence record | Terminal | Verify or export evidence |
Never mutate a ready-to-sign artifact. A corrected address or replaced policy page creates another packet revision and another hash. This costs storage, which is usually the right trade: reconstruction months later is unreliable when templates, OCR models, or rendering libraries have changed.
How should one job fill, merge, and sign an HR onboarding packet?
Make orchestration a pure decision layer around replaceable ports. The worker loads a pinned input manifest, obtains searchable text for scanned attachments, fills controlled templates, merges in a deterministic order, validates the assembled packet, stores immutable bytes, and then asks the signing adapter to create a request tied to that exact hash. This example concentrates on boundaries rather than a PDF library or commercial API, because those choices should not leak into job semantics.
package packet
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
)
type Source struct {
ObjectVersion string
ExpectedHash string
NeedsOCR bool
}
type Job struct {
ID string
EmployeeID string
TemplateRevision string
Sources []Source
Fields map[string]string
SignerEmail string
}
type OCRResult struct {
SearchablePDF []byte
Confidence float64
}
type Ports interface {
LoadSource(context.Context, Source) ([]byte, error)
ExtractText(context.Context, []byte) (OCRResult, error)
Fill(context.Context, string, map[string]string) ([]byte, error)
Merge(context.Context, [][]byte) ([]byte, error)
Validate(context.Context, []byte) error
PutImmutable(context.Context, string, []byte, string) error
RequestSignature(context.Context, string, string, string) (string, error)
}
func AssembleAndSign(ctx context.Context, p Ports, job Job, minConfidence float64) (string, error) {
parts := make([][]byte, 0, len(job.Sources)+1)
filled, err := p.Fill(ctx, job.TemplateRevision, job.Fields)
if err != nil {
return "", fmt.Errorf("fill template: %w", err)
}
parts = append(parts, filled)
for i, source := range job.Sources {
body, err := p.LoadSource(ctx, source)
if err != nil {
return "", fmt.Errorf("load source %d: %w", i, err)
}
if digest(body) != source.ExpectedHash {
return "", fmt.Errorf("source %d hash mismatch", i)
}
if source.NeedsOCR {
result, err := p.ExtractText(ctx, body)
if err != nil {
return "", fmt.Errorf("extract source %d: %w", i, err)
}
if result.Confidence < minConfidence {
return "", fmt.Errorf("source %d requires review", i)
}
body = result.SearchablePDF
}
parts = append(parts, body)
}
packet, err := p.Merge(ctx, parts)
if err != nil {
return "", fmt.Errorf("merge packet: %w", err)
}
if err := p.Validate(ctx, packet); err != nil {
return "", fmt.Errorf("validate packet: %w", err)
}
packetHash := digest(packet)
if err := p.PutImmutable(ctx, job.ID, packet, packetHash); err != nil {
return "", fmt.Errorf("store packet: %w", err)
}
// The job ID is the idempotency key; the hash binds the request to these bytes.
return p.RequestSignature(ctx, job.ID, packetHash, job.SignerEmail)
}
func digest(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
The interface deliberately does not claim that hashing a PDF creates a digital signature. SHA-256 identifies the exact bytes supplied to the signing stage; signature validity also depends on the signature format, certificate chain, trust policy, revocation information, time evidence, and verifier behavior. NIST defines SHA-256 in FIPS 180-4, while ETSI documents PAdES profiles for PDF advanced electronic signatures. Legal acceptance and identity requirements vary by jurisdiction, so engineering review does not replace legal review.
There is another catch: deterministic assembly does not guarantee byte-identical output if the renderer inserts timestamps, random IDs, or changing metadata. Pin the renderer and fonts, remove nondeterministic metadata where the format permits it, and test semantic invariants as well as hashes. Byte identity is useful for retries inside a pinned build; page manifest, field presence, and signature validation are the durable correctness claims across upgrades.
Verify the packet, the evidence, and the operating envelope
Verification needs layers. Unit tests should cover field rules, state transitions, idempotency, and document ordering. Golden fixtures should include rotated scans, blank pages, mixed page sizes, missing fonts, repeated form field names, and a packet near the configured size limit. Integration tests should run through the same parser and signer-verification path used in production without sending real employee data to test systems.
A useful pre-release check produces a manifest containing the ordered logical document names, page ranges, source hashes, template revision, filled-field schema version, final hash, and intended signer. After completion, verify the cryptographic signature with an independent verifier, record the validation result and trust policy, and confirm that the signed document corresponds to the stored pre-signature revision. Keep the provider event trail, but do not let it be the only audit trail; your own append-only transitions explain who authorized the packet, which inputs were used, and why the system moved forward.
Observe each stage with job ID, packet revision, attempt, duration, result class, dependency, and artifact size. Do not put names, email addresses, extracted text, tax identifiers, or document contents in metrics and logs. Alert on sustained SLO burn, oldest-ready-job age, review backlog, reconciliation age, and validation failures. Raw failure counts page teams during ordinary traffic growth; rates and burn windows provide the capacity context an on-call engineer needs.
The buy-versus-build decision should be made per capability, not once for the whole pipeline:
| Capability | Buy when | Build or self-host when | Principal burden |
|---|---|---|---|
| OCR | Language coverage and managed model updates matter | Data locality or specialized forms dominate | Evaluation drift and review tooling |
| PDF assembly | Standard templates fit a stable API | Layout control and deterministic rendering are strict | Font, parser, and format maintenance |
| Electronic signature | Identity, consent, and evidence workflows are required | A narrow internal approval is legally sufficient | Compliance, trust, and verifier lifecycle |
| Orchestration | Managed retries meet the audit model | State and reconciliation need tight control | On-call ownership and migration work |
Managed services are not suitable when their data residency, retention, signer identity, export, or evidence format cannot meet the contract. Self-hosting is a poor choice when the team cannot staff parser security updates, certificate and trust-store maintenance, OCR evaluation, and a 24-hour on-call rotation. The quiet risk is lock-in at the evidence layer: if signed artifacts can be exported but the validation material or event history cannot, migration may preserve files while discarding proof.
This one-job design is not suitable for an interactive document editor where several people repeatedly change page content before approval; use a collaborative draft store first, then freeze one revision and hand that immutable artifact to the packet job. It is also excessive for a low-risk internal checklist with no documents, signatures, or retention duty. In that case, stick with a transactional workflow row and an ordinary event log instead of operating PDF normalization, OCR review, and signature verification infrastructure.
Short version: choose the operating model whose evidence you can independently verify and retain.
No shortcuts.
Roll back without rewriting history
Rollback means stopping new work on a faulty application version, draining or pausing affected stages, and replaying only jobs whose last transition is known. It does not mean deleting audit rows or replacing signed bytes. Version the workflow, templates, field schema, OCR configuration, renderer, and adapters; then keep the old reader path available long enough to inspect packets created by the previous version.
For a bad template deployment, identify every job that references its revision. Jobs before ready_to_sign can be canceled and rebuilt from pinned sources under a corrected revision. Jobs already sent for signature require a business action: void the request through the signing boundary, record the reason, and create a new packet revision. Completed packets remain immutable and follow the organization's correction and retention procedure.
Practice this. A quarterly game day can inject a signing timeout, an OCR review surge, a changed source hash, and a renderer rollback while operators work only from dashboards and the runbook. The success condition is not that every job finishes; it is that no job signs unknown bytes, ambiguous side effects are reconciled, and the audit history still explains every decision.
Top comments (0)