| Design | Signature evidence | Audit trail | Best fit | Main cost |
|---|---|---|---|---|
| Synchronous request | Tied to one web request | Usually application logs | Internal, low-consequence exports | Long requests and weak replay evidence |
| Durable job plus signed manifest | Bound to the exact output hash | Explicit state events | Customer-facing merge and split delivery | Queue, worker, and cleanup operations |
| External document workflow | Defined by the provider contract | Split across system boundaries | Teams outsourcing document operations | Less control over evidence shape and retention |
Short answer: for branded B2B document bundles, persist an asynchronous job, produce a signed evidence manifest only after the output is final, and treat secure temporary files and retention deadlines as state transitions rather than cleanup details. Choose an external workflow when its signature and audit contract already matches yours; choose the synchronous path only while interruption and replay carry little business risk.
That is my decision note. Signature evidence and audit ownership decide the architecture, because a fast PDF is useless when support can't establish which inputs produced it, who was allowed to fetch it, or why it still exists. For a one-person SaaS, this is also a revenue-per-hour call: keep the evidence contract in the application, then outsource undifferentiated conversion or storage behind narrow adapters when operating it stops paying for itself.
Start with the evidence, not the queue
A merge or split changes the document's identity. The resulting bytes need their own digest, and the evidence record needs to connect that digest to the tenant, operation, ordered inputs, branding revision, creation time, and retention deadline. Sign that record after generation and validation. Signing a request before the output exists proves only that somebody requested work; it does not prove which artifact was delivered.
Keep delivery authorization separate from artifact evidence. A temporary download URL answers, “may this caller fetch these bytes right now?” The manifest answers, “which bytes did this completed job produce?” Mixing those questions makes rotation and expiry painful. The access token can expire in minutes while the redacted audit record remains useful for the period allowed by policy.
Proof comes first.
An audit stream should capture meaningful decisions: accepted, leased, validated, completed, delivery authorized or denied, and deleted. Avoid copying file contents, bearer tokens, customer names, or original filenames into events. An append-only shape helps operators reason about order, but “append-only” is not permission to retain personal data forever. The event payload should be sparse enough that deleting the source and output actually reduces exposure.
The signature boundary also settles a subtle merge question: input order is data. Sort keys only when the product contract says order is irrelevant. For a contract packet or invoice bundle, changing order changes meaning, so hash a canonical manifest that preserves the requested sequence. A split job should identify each child output and its digest rather than attaching one vague signature to a directory.
How should asynchronous jobs validate secure temporary files and retention?
Model the workflow as a persisted state machine. A useful path is accepted -> running -> validated -> completed -> deleted, with rejected for permanent input failures and retryable for work that may succeed later. The database is the authority; the queue message carries only a job ID. This keeps document bytes and credentials out of queue payloads and lets a redelivered message inspect current state before doing work twice.
Validation happens before and after transformation. At acceptance, check tenant authorization, the requested merge or split operation, the number of object references, and an idempotency key bound to a digest of the normalized request. In the worker, inspect the fetched bytes, enforce the application's file limits, run the required safety checks, and validate the generated output before signing its manifest. Client-supplied MIME metadata is not enough; OWASP's file upload guidance recommends defense in depth rather than trusting Content-Type alone.
Retries belong to error classes, not to a catch-all loop. Retry a temporary storage or lease failure with bounded exponential backoff and jitter. Reject malformed input, an authorization mismatch, or a request that violates policy without retrying it. Record the classification. Otherwise, three identical failures look like activity while consuming the same scarce thing a solo operator is protecting: attention.
Temporary workspace rules should be mechanical:
- Create a random directory outside the served application tree.
- Write opaque filenames with owner-only permissions.
- Give the workspace a deadline linked to the job lease.
- Delete it in
finally, then let a separate reaper remove abandoned expired directories. - Store the finished object privately and issue access only after a fresh tenant authorization check.
The retention deadline belongs on the job and output records at creation time. A deletion worker can then scan durable records, remove eligible objects, and append a minimal deletion event. Legal hold, if the product offers it, must be an explicit policy state that blocks deletion; it should never be inferred from a missing cleanup run. I'm not sure a 15-minute workspace deadline fits every workload. The right value comes from the largest supported bundle, the worker lease, and measured processing time, with enough margin to cleanly finish or abort.
Implement one narrow job contract
The following TypeScript keeps the interfaces generic. The adapters can point to self-hosted components or managed services without changing the job contract. It also makes the signature boundary visible: the manifest is assembled from the final bytes, then signed, then committed with the output record.
import { createHash, randomUUID, sign } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
type Operation = "merge" | "split";
type BundleJob = {
id: string;
tenantId: string;
operation: Operation;
inputKeys: string[];
brandingRevision: string;
retainUntil: string;
};
type EvidenceManifest = {
jobId: string;
tenantId: string;
operation: Operation;
inputKeys: string[];
brandingRevision: string;
outputKey: string;
outputSha256: string;
createdAt: string;
retainUntil: string;
};
type Dependencies = {
readPrivateObject(key: string, tenantId: string): Promise<Uint8Array>;
transform(operation: Operation, paths: string[]): Promise<Uint8Array>;
validateInput(bytes: Uint8Array): Promise<void>;
validateOutput(bytes: Uint8Array): Promise<void>;
commitOutput(input: {
key: string;
bytes: Uint8Array;
manifest: EvidenceManifest;
signature: string;
}): Promise<void>;
appendAudit(event: Record<string, string>): Promise<void>;
signingKeyPem: string;
};
const sha256 = (bytes: Uint8Array): string =>
createHash("sha256").update(bytes).digest("hex");
export async function processBundle(
job: BundleJob,
deps: Dependencies,
): Promise<{ outputKey: string; outputSha256: string }> {
const workspace = await mkdtemp(join(tmpdir(), "document-job-"));
try {
const paths: string[] = [];
for (const [index, key] of job.inputKeys.entries()) {
const bytes = await deps.readPrivateObject(key, job.tenantId);
await deps.validateInput(bytes);
const path = join(workspace, `${index}-${randomUUID()}.pdf`);
await writeFile(path, bytes, { mode: 0o600 });
paths.push(path);
}
const output = await deps.transform(job.operation, paths);
await deps.validateOutput(output);
const outputSha256 = sha256(output);
const outputKey = `private-deliveries/${job.id}/${randomUUID()}.pdf`;
const manifest: EvidenceManifest = {
jobId: job.id,
tenantId: job.tenantId,
operation: job.operation,
inputKeys: job.inputKeys,
brandingRevision: job.brandingRevision,
outputKey,
outputSha256,
createdAt: new Date().toISOString(),
retainUntil: job.retainUntil,
};
const canonicalManifest = JSON.stringify(manifest);
const signature = sign(
"sha256",
Buffer.from(canonicalManifest),
deps.signingKeyPem,
).toString("base64url");
await deps.commitOutput({ outputKey, bytes: output, manifest, signature });
await deps.appendAudit({
jobId: job.id,
tenantId: job.tenantId,
event: "completed",
outputSha256,
at: manifest.createdAt,
});
return { outputKey, outputSha256 };
} finally {
await rm(workspace, { recursive: true, force: true });
}
}
This example deliberately leaves PDF parsing, scanning, transformation, storage, and key custody behind dependencies. Those are separate risk domains. The contract says what each must guarantee, while the application retains the branded bundle's evidence model. It can ship weekly without coupling every release to a particular queue or converter.
The inputKeys in the manifest are internal opaque identifiers. If even those identifiers are sensitive under your data model, store their digests or references to a separately governed input ledger. The exact choice depends on what an auditor must reconstruct and what a deletion request must erase. Write that answer down before choosing an event schema; changing an overstuffed audit log later is slow work.
Test the transitions operators will actually see
Unit tests around the happy-path transform are necessary, but the valuable tests sit between states. Deliver the same queue message twice and assert that only one completed output becomes authoritative. Stop a worker after it writes one input, then confirm the expired workspace reaper can remove the directory. Advance the policy clock beyond retainUntil and verify that download authorization fails before deletion begins. Change one output byte and confirm signature verification rejects the manifest-to-file pairing. Do the same for privacy boundaries: assert that logs and audit payloads contain no bearer token, signed URL, document bytes, customer-facing filename, or extracted text; give one tenant another tenant's object key and require denial before the worker reads the object; and, for split operations, verify every child output has an identity and can expire under the declared bundle policy. This long failure-path test is worth more than a dozen snapshots of a successful response because it checks the promises customers will ask about after access ends.
Then test deletion.
Observability should answer a handful of operational questions: how many jobs remain in each state, how old the oldest lease is, how many retries occur by class, which cleanup deadlines were missed, and whether signature verification ever fails. Alert on stuck age and missed deletion deadlines, not raw queue depth alone. A short burst may be normal; an old job with repeated leases is actionable.
Don't page on everything.
Deployment deserves one compatibility rule: a worker must understand the persisted job schema it leases. Add a schema version to the durable record when the contract begins changing, and keep consumers compatible through the rollout. Blue-green web deployments don't protect a queue when yesterday's job is picked up by today's worker.
When should you choose the runner-up?
The durable-job design is not suitable when a document must be produced inside an interactive request and losing that request has no customer or compliance consequence. In that narrow case, an in-process path has fewer moving parts. Persist the result if it later becomes a system of record, and don't quietly let the “temporary export” endpoint become permanent delivery infrastructure.
Choose an external document workflow when its data residency, deletion, key custody, signature format, and audit export meet the contract you already wrote, and when operating converters and scanners would crowd out paid product work. The catch is boundary ownership: your application still needs stable tenant authorization, idempotency, and a record of what it asked the external system to do. Outsourcing execution does not outsource accountability.
Use a content platform instead when users need permanent public links, collaborative editing, or broad records discovery. Those are different product requirements, not extensions of a temporary branded bundle pipeline. A queue and a signed manifest won't turn a delivery feature into a full content-management system.
For a solo SaaS, the final rule is blunt: own the small contract that proves what happened; rent or replace the machinery behind it. Signature, audit, privacy, and retention then remain product decisions even as the infrastructure changes.













