Document Generation
Audience: Engineers configuring document templates or the generation pipeline; paralegals understanding gap placeholders.
What you will accomplish: Understand the 9-step document generation pipeline — from template loading to audit record — including gap detection, versioning, and QC checks.
Prerequisites: Read Core Services first.
Estimated time: ~8 minutes.
Turn matter and contact data into filled DOCX/PDF documents — engagement letters, standard forms, routine correspondence — with gap placeholders for missing data, version tracking, and QC checks before storage.
Tools
document.generate— Full pipeline: template → render → QC → store → versionform.prefill— Fill known fields on standard forms (I-130, I-485, N-400, G-28); surface gaps for human completion
Both are risk tier write (confirm) — reversible drafts stored, nothing sent or filed externally.
Pipeline
Step 1: Load template
Retrieve template from TemplateStore by name. Template declares required variables, optional variables, and default values.
TemplateStore.get(template_name)Step 2: Resolve context and detect gaps
Merge Matter, Contact, and data_context overrides. Classify missing data: missing / empty / type_mismatch / enum_violation. Required vars missing → gap placeholders. Optional vars missing → empty string or default.
Context resolver + Gap detectorStep 3: Idempotency check
Check idem_key against existing Document. Same key + same content → return existing. Same key + different content raises VersionCollisionError at the store step, not here.
IdempotencyStoreStep 4: Render DOCX
docxtpl + Jinja2 fills the template. Gap vars render as [[MISSING: label]] placeholders in the output.
docxtpl.render(template, context)Step 5: PDF convert
Convert DOCX to PDF using the library-configured engine (LibreOffice headless or WeasyPrint). If engine unavailable and PDF is optional, skip. If PDF is mandatory, fail.
to_pdf(docx_bytes)Step 6: Checksum rendered artefact
Compute SHA-256 of the primary rendered bytes (DOCX or PDF, whichever is the primary format). Checksum stored on Document record.
sha256(primary_bytes)Step 7: QC verification
Run completeness + consistency checks. Are all required template vars resolved? Are names/dates consistent across all docs in the packet?
qc.verifyStep 8: Store via DocStoreConnector
Store the rendered document in the document system. Version is monotonic per (matter_id, template_name, doc_key). Old versions immutable. Same idem_key + different content → VersionCollisionError.
DocStoreConnector.put — write (confirm)Step 9: Audit record
Write hash-chained audit record before returning. Record includes template name, version, checksum, and gap summary (PII-scrubbed).
AuditService.record
Status derivation
| Condition | Status | Meaning |
|---|---|---|
| Gaps present | incomplete | Needs human attention to fill gaps |
QC fail | blocked | Cannot be used until QC issues fixed |
QC skipped (unavailable) | incomplete | Cannot certify completeness |
QC pass/warn + no gaps | ready | Ready for routing or sending |
| Render/store error | failed | Technical failure, retry or triage |
Versioning
- Monotonic per
(matter_id, template_name, doc_key) doc_key= semantic identifier (e.g.,engagement_letter,i130_petition)- Old versions are immutable — no overwrite
Document.checksum= SHA-256 of rendered primary bytes
Gap placeholders
Unresolved required variables render as [[MISSING: label]] in the output. This is intentional — it gives the human a concrete artefact to fix, rather than a blank space they might miss.
Dear [[MISSING: client_full_name]],
Thank you for engaging [[MISSING: firm_name]] to represent you in your
[[MISSING: case_type]] matter.Idempotency
idem_key= explicit key ORsha256(template_name + version + canonical(context))[:24]- Same key → return existing Document, no new version
- Same key + differing content is accepted through the idempotency check;
VersionCollisionErroris raised later at the DocStoreConnector.put store step (version_store.py) if the existing version has different content — refused, human triage required
PDF engine
| Engine | Status | Fidelity | Speed | Dependency |
|---|---|---|---|---|
| LibreOffice headless | Preferred (stub) | High (full DOCX fidelity) | Slower | libreoffice binary |
| WeasyPrint | Fallback (stub) | Medium (HTML→PDF) | Faster | Python package |
The engine is configured in the library code, not via an environment variable.
ASSUMPTION (confirm)
- D-001 — Priority target forms for
form.prefill(I-130, I-485, N-400, G-28) - D-002 — Default PDF engine and primary format (DOCX vs HTML)
- D-003 — Behaviour when PDF mandatory and conversion fails
- D-004 — Behaviour when QC unavailable (block vs warn vs incomplete)
- D-005 — Default privilege classification (currently:
true)
Example: generating an engagement letter
from cam.core.workflows.document_gen import document_generate
result = await document_generate(
template_name="engagement_letter_v2",
matter_id="mat_abc123",
data_context={
"fee_structure": "flat_fee_5000",
"retainer_amount": "2500.00",
},
idem_key="engagement:mat_abc123:2026-06-01",
)
print(result.status) # ready, incomplete, or blocked
print(result.document_id) # doc_xyz789
print(result.gaps) # [] or [Gap(field="client_full_name", reason="missing")]Verification
document.get(result.document_id)returns the Document with correct version and checksum- Gap placeholders render as
[[MISSING: label]]in the output, never empty strings - Audit log shows
document.generatewith template name, version, checksum, and gap summary - Old versions are accessible via version history API
Next steps
- Document Routing — Classify, file, and permission the generated document
- Client Intake Workflow — Where document generation is triggered after matter creation
- Core Services: Document Generation — Engine internals
Last updated: 2026-06-01