TL;DR: Put contract rendering in a job, then sign the exact output bytes. For an edtech platform, the least complex dependable design is a school-owned template, a versioned render request, and an immutable audit record connecting input, template, PDF, and signature. Page count is an output of layout, not a safe scheduling input.
| Template model | Who controls change? | Reproduction story | Operational cost |
|---|---|---|---|
| School-owned, versioned template | School or platform team | Retain the exact version and inputs | More validation up front |
| Renderer-owned template | Rendering system | Depends on retained renderer state | Less local tooling, more external state |
| Hybrid template plus remote assets | Ownership is split | Every dependency must be pinned | Highest audit burden |
Recommendation: keep the contract template under school control and render it in an asynchronous job. Do not reserve work by predicted page count. Reserve it by a stable job identity, persist each transition, and discover page count from the completed PDF.
This is a template-ownership decision before it is a queue decision. A queue cannot repair a mutable template or an untracked font. It can give the render enough time, isolate retries from the signing request, and make every attempt visible.
Why does document rendering belong in a job when page count changes?
A contract is a layout program fed with variable data. Four inputs commonly expand it: a long legal name wraps; a course schedule gains rows; an optional consent clause appears; or a signature block moves because the preceding paragraph crossed a page boundary. A single wrapped line near the bottom margin can push a block forward and create another page. These are concrete cases, but they are not independent counters. The renderer resolves them together, in order, against one finite page box.
Consider two enrollment agreements using one template. Agreement A has Ada Li, three course rows, and no optional media clause. Agreement B has Alexandria Montgomery-Santos, eleven course rows, and the clause. The examples do not prove that B adds exactly one or two pages. That is the point. Font metrics, available width, row splitting, keep-together rules, headers, footers, and page-break policy decide the result together. Counting characters or rows outside the renderer only creates a second, less accurate layout engine.
Fonts sharpen the boundary. If a requested font is unavailable and another font is used, glyph widths can change line breaks. A remote logo can change dimensions or fail to resolve. Late template edits can alter margins. The input data may be identical while an unpinned rendering environment produces different bytes. For a signed contract, that ambiguity is unacceptable.
PDF is the final document format here, not the page-estimation algorithm. ISO 32000-2 specifies the PDF format, but the page tree exists after document construction. Treat the completed file as the authority for its page count.
No guesswork.
Four traps. One output.
Template ownership comes first
The owner must answer a blunt audit question: which template produced this signed agreement? A filename is insufficient. enrollment-final-v2 can be overwritten, and a URL can return different content later. Use a content digest or an immutable template version, and retain the exact rendering inputs under the retention rules that apply to the contract.
School-owned templates fit teams that need reviewable changes and deterministic rollback. A change to cancellation language can move through the team's approval controls. The important property is stable identity. The renderer receives a reference that never silently changes.
Renderer-owned templates reduce local template infrastructure, but move part of the audit trail outside the application. Before choosing that boundary, verify that an old version can be rendered again and that its identifier is exported into your records. A hybrid design looks flexible, yet every remote font, image, partial, and stylesheet becomes another item to pin. Config spreads quickly. I would accept that burden only when distributed ownership is a real organizational requirement.
This criterion has teeth: if the team cannot preserve a template version, it is not ready to sign the output.
Runtime variance comes second
An inline endpoint couples the caller's connection lifetime to fetching assets, laying out pages, producing PDF bytes, inspecting the result, signing it, and writing the audit record. Any stage can outlast the request budget. Retrying the whole HTTP request can also repeat work unless the operation has a durable identity.
A job changes the contract. The API validates intent, records it, and returns a job ID. A worker renders independently. The signer receives only completed bytes whose digest has already been recorded. The client polls or consumes an application event; it does not hold a socket open while page layout runs.
Queues cost something.
The job boundary should survive retries without creating two signed agreements. Give each logical request an idempotency key. Record attempts separately from the logical job, because an attempt is operational history while the job is business intent. Timeouts belong to attempts. Terminal failure belongs to the job only after the retry policy is exhausted or an error is known to be permanent. The explicit trade-off is more machinery: durable state, a worker deployment, idempotency handling, retry policy, and cleanup for abandoned work. That overhead is a real limitation. A team whose unsigned previews are fast, disposable, and tightly bounded should keep them inline instead of copying this architecture by habit.
Do not use page count as a retry key, deduplication key, or queue partition. It is unknown until rendering finishes and can change when a legitimate template revision lands. Input size can guide coarse worker protection, but it cannot replace measuring actual duration, memory, output bytes, and pages after every run. Benchmark the real corpus. Synthetic one-page samples hide the exact tail that matters.
A small TypeScript job contract
The useful abstraction is narrow: immutable references go in; immutable PDF bytes and measured metadata come out. This keeps renderer-specific configuration behind one interface and prevents signing from racing rendering.
type RenderRequest = {
jobId: string;
idempotencyKey: string;
templateVersion: string;
contractData: Readonly<Record<string, unknown>>;
};
type RenderedDocument = {
pdf: Uint8Array;
pageCount: number;
sha256: string;
};
interface ContractRenderer {
render(request: RenderRequest): Promise<RenderedDocument>;
}
interface ContractSigner {
sign(pdf: Uint8Array): Promise<Uint8Array>;
}
interface AuditStore {
start(request: RenderRequest): Promise<"started" | "already-complete">;
recordRendered(jobId: string, result: RenderedDocument): Promise<void>;
recordSigned(jobId: string, signedSha256: string): Promise<void>;
recordFailure(jobId: string, code: string): Promise<void>;
}
The worker is deliberately boring. Good. There is one path to rendering, one handoff to signing, and no collection of page-based knobs.
async function processContract(
request: RenderRequest,
renderer: ContractRenderer,
signer: ContractSigner,
audit: AuditStore,
sha256: (bytes: Uint8Array) => Promise<string>,
): Promise<void> {
const state = await audit.start(request);
if (state === "already-complete") return;
try {
const rendered = await renderer.render(request);
await audit.recordRendered(request.jobId, rendered);
const signedPdf = await signer.sign(rendered.pdf);
await audit.recordSigned(request.jobId, await sha256(signedPdf));
} catch (error) {
const code = error instanceof Error ? error.name : "UnknownFailure";
await audit.recordFailure(request.jobId, code);
throw error;
}
}
A production record needs more than these interfaces show. Capture the template version, normalized input digest, render start and finish times, attempt number, renderer build identity, output digest, measured page count, signing identity, and final signed digest. Keep confidential contract data out of general-purpose logs; reference the protected record by job ID.
Observability should answer where time went. Measure queue delay separately from render duration and signing duration. Track output size and page count as distributions, then correlate failures with template versions. A single average is weak evidence because long contracts are more likely to expose layout and memory limits.
Testing follows the same boundary. Keep fixtures for short and long names, zero and many table rows, optional clauses, forced page breaks, and signature blocks near a page edge. Assert the resulting page count where the environment is pinned, but also inspect text and signature placement. A stable count can still hide clipped content.
When is the runner-up better?
Renderer-owned templates can be the better boundary when a separate document team must change presentation without an application deployment and the rendering system provides immutable versions that the application can record. The gain is organizational speed. The cost is a cross-system audit dependency, so test version retrieval and export before accepting it.
Inline rendering is reasonable for a preview that is explicitly unsigned, disposable, and allowed to fail with the request. It may also fit a tightly bounded internal document when the full input corpus proves rendering stays inside the request budget. That evidence must come from measurements, not a page estimate. Once the result becomes a contract, needs retries, or feeds a signer, the job boundary earns its keep.
The queued approach is also a poor fit when the application has no durable job store and cannot operate one. Losing job state would weaken the audit story it was meant to improve. In that case, use a synchronous boundary only for bounded, unsigned work, or establish durable execution before moving contract signing onto it. Template ownership has its own limitation: school ownership adds review, packaging, and compatibility work to every template change. Choose renderer ownership when a document team genuinely owns that lifecycle and can export immutable version evidence.
The decision rule is short: own the template version, queue the render, measure the produced PDF, then sign those exact bytes. Page count belongs in the completed audit record. It does not belong in the promise made before layout starts.
Further reading
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
Top comments (1)
You need to complete account verification.Link in the profile.