Core Services
Audience: Engineers extending or configuring the deadline engine, QC checks, document generation, or extraction pipeline.
What you will accomplish: Understand the four core services — their contracts, failure modes, and extension points — so you can configure, extend, or debug them.
Prerequisites: Read Workflow Engine first to understand how services are invoked from step handlers.
Estimated time: ~15 minutes.
Core services are the reusable engines shared by all workflows. They live in cam.core.services/ and are injected into workflow handlers through the ServiceContainer.
Deadline Engine
Business-day deadline computation, redundant reminders, escalation, and dead-man's-switch monitoring.
QC Verification
Composable check registry — 7 built-in checks, any-fail-blocks, proven by property-based tests.
Document Generation
Template-driven DOCX/PDF pipeline with gap placeholders, versioning, and checksum verification.
Data Extraction
PDF text + OCR + LLM structuring pipeline with per-field confidence scoring and low-confidence flagging.
Deadline Engine
The deadline engine is a liability-critical subsystem. It is treated as a first-class, independently-monitored component with redundant scheduling and a dead-man's-switch.
Rule sets
Rules are data, not code — authored as YAML, versioned by content hash (rule_version = sha256(yaml)[:16]). The registry is append-only; old versions are never deleted.
rule: statute_of_limitations_personal_injury
jurisdiction: US
trigger: incident_date
offset:
years: 2
adjust: next_business_day
reminders: [-90d, -30d, -7d, -1d]
escalation:
after_due: notify_supervisorComputation trace
Every deadline.compute result includes a ComputationTrace that records how the due date was derived:
class ComputationTrace(BaseModel):
rule_id: str
rule_version: str # content hash of the rule definition
jurisdiction: str
trigger_date: date
raw_due: date # before business-day adjustment
adjustments: list[str] # e.g. "skipped Saturday", "skipped US Independence Day"
final_due: date # after all adjustmentsThis trace is persisted in the audit log, so any computed deadline can be independently verified against its rule version.
Key types
class RuleRef(BaseModel):
rule_id: str
rule_version: str # content hash of the rule definition
jurisdiction: str
class ScheduledDeadline(BaseModel):
id: str
matter_id: str
name: str
due_at: datetime
rule: RuleRef
computation: ComputationTrace
status: Literal["pending", "reminded", "done", "missed", "past_due"]
escalation_level: intdeadline.schedule returns a ScheduledDeadline — not a bare Deadline — so the caller receives both the rule provenance and the full computation trace in one response.
Business-day computation
All date math goes through the Calendar abstraction. No timedelta(days=n) is applied directly to business-day offsets.
from cam.core.services.deadline.calendar import Calendar
cal = Calendar("US")
due = cal.add_business_days(trigger_date, 90) # skips weekends and US federal holidaysMissing holiday data for a year → loud failure, never a guess.
Reminder arming
Each reminder is armed via two independent scheduler paths sharing one idempotency key. This gives exactly-once delivery even if one scheduler path fails:
- Path A: Celery beat fires
fire_remindertask - Path B: Sweep task finds armed but un-fired reminders and re-arms
Both paths check cam:reminder_fired:{idem_key} in Redis before executing.
Escalation
escalation_level is monotonically non-decreasing. The recipient set only widens:
| Level | Action |
|---|---|
| 0 | Remind assignee |
| 1 | Remind assignee + supervisor |
| 2 | Remind assignee + supervisor + operations |
| 3 | Page on-call |
Past-due + unsatisfied → Deadline.status = missed + safety incident log.
Dead-man's-switch
Runs on an independent timer (not co-scheduled with the reminder scheduler). If the scheduler heartbeat age exceeds the staleness threshold (default 300s), a safety incident is paged immediately.
A missed deadline due to a scheduler bug is a malpractice liability. The engine never silently fails: it retries, escalates, pages, and logs everything.
Past-due-on-create
When deadline.schedule computes a due_at that is already past, the engine:
- Raises
PastDueOnCreateError— not silently persisted - Flags the deadline as
past_duebut does not arm reminders - The calling workflow logs the flag and continues (not parked)
This ensures a human sees the flag, but the workflow is not blocked by a deadline that was already tight at creation time.
Reconciliation
A periodic reconciliation sweep compares persisted Deadline records against the case management system's current state:
- Drift detection: changed due dates, closed matters, new deadlines from the case system
- Alert-only: reconciliation never auto-mutates the source of record; it surfaces discrepancies for human triage
- ConnectorError during reconciliation: parks the reconciliation run and alerts operations
- Absence of case data is NEVER treated as "no drift" — missing data is itself a signal
Scheduler abstraction
The deadline engine depends on a Scheduler protocol, not a specific backend. The current implementation uses Celery beat (confirmed in ASSUMPTIONS H-003), but the engine can swap to APScheduler or any backend that satisfies the protocol.
QC Verification
The QC service (cam.core.services.qc) is a pluggable check registry. It runs a set of composable checks against a VerificationPacket and returns a QCReport.
Verdict model
| Verdict | Aggregate impact |
|---|---|
pass | Contributes to pass or pass_with_warnings |
warn | → pass_with_warnings (unless a fail exists) |
fail | → block immediately |
skipped | Ignored in aggregation; listed in checks_skipped |
Key invariant: any single fail verdict → aggregate = block.
The seven built-in checks
| Check ID | Verifies | Can emit warn? | Severity |
|---|---|---|---|
completeness | Required template vars + matter fields present | yes | high |
consistency | Name/ref consistency across artefacts | yes | high |
attachment_integrity | Attachments present + checksum match | yes | medium |
recipient_integrity | Recipients ∈ matter participants | yes | high |
privilege | No privileged doc to external recipient | no — fail or pass only | critical |
deadline_sanity | Deadlines plausible, not past-due | yes | medium |
extraction_confidence | Field confidence ≥ threshold | yes | medium |
Privilege check
The privilege check is fail-closed:
document.privileged=True+ external target → alwaysfail- No approval token can override this
- Unknown privilege + external →
fail(default-deny)
This is proven by a Hypothesis property-based test (test_pbt_privilege_never_passes_external) that generates hundreds of (privileged, external) combinations.
Adding a new check
from cam.core.services.qc import register_check, Check, CheckResult
class MyCustomCheck(Check):
id = "my_check"
version = 1
applies_to = ["email_send", "document_generate"]
severity_policy = "warn_allowed"
async def run(self, packet: VerificationPacket, cfg: QCConfig) -> CheckResult:
# ... validation logic ...
return CheckResult(status="pass", reason="All good")
register_check(MyCustomCheck())No edits to registry core needed (FR-16). The registry discovers and runs it automatically.
Packet kinds
The VerificationPacket carries a kind field that determines which checks apply:
class VerificationPacket(BaseModel):
kind: PacketKind
template_bindings: list[TemplateBinding] # template var → value bindings
attachments: list[AttachmentRef] # document references with checksums
external_bound: bool # any recipient is external?
now: datetime # reference timestamp
config_snapshot: QCConfig # frozen config at verification time
config_fingerprint: str # sha256 of config_snapshot JSONThe config_snapshot is frozen at verification time and its config_fingerprint is stored in the QCReport. This means any QC result can be traced back to the exact configuration that produced it — essential for audit and reproducibility.
| PacketKind | Typical checks |
|---|---|
email_send | recipient_integrity, privilege, attachment_integrity |
document_generate | completeness, consistency |
document_route | privilege, recipient_integrity, attachment_integrity |
intake_finalize | completeness, extraction_confidence, deadline_sanity |
Totality invariant
Every selected check either returns a CheckResult or a skipped entry with a SkipReason (not_applicable, field_absent, disabled, unsupported_packet_kind). No check can silently pass or be omitted. The QCReport lists both checks_selected and checks_skipped with reasons.
Severity policy enforcement
Each check declares which verdicts it may emit via severity_policy. A check configured as fail_only (like privilege) cannot downgrade to warn. The registry enforces this at runtime — a misconfigured check that attempts an invalid verdict is treated as a fail. This prevents accidental downgrading of safety-critical checks.
Configuration
| Variable | Default | Purpose |
|---|---|---|
CAM_FEATURE_FLAGS['qc'] | false | Feature flag for QC verification (set via CAM_FEATURE_FLAGS JSON blob) |
QCConfig thresholds | per-check | Extraction warn/fail floors, deadline sanity horizon |
Document Generation
The document generation pipeline (cam.core.workflows.document_gen) produces DOCX and PDF artefacts from Jinja2 templates and matter/contact data.
Pipeline
template (DOCX/HTML, Jinja2 vars)
+ matter/contact data (domain model)
│
▼
render (docxtpl / Jinja2) ──► DOCX
│ └─► WeasyPrint / LibreOffice ──► PDF
▼
QC.verify (all vars resolved? names/dates consistent?)
▼
store (object store / doc connector) ──► Document(version, checksum)
▼
route (document.route) ──► correct matter/folder + ACLGap placeholders
Unresolved required variables → [[MISSING: label]] token in output. Never an empty string (FR-9). The human receives a concrete artefact with explicit gaps to fix.
Status derivation
- gaps present →
incomplete - QC
fail→blocked - QC
skipped(unavailable) →incomplete - QC
pass/warn+ no gaps →ready - Render/store error →
failed
Versioning
Versions are monotonic per (matter_id, template_name, doc_key):
doc = Document(
id="doc_abc",
matter_id="mat_123",
name="Engagement Letter",
version=3,
checksum="sha256:abc...",
)Old versions are immutable. Nothing overwrites silently.
Data Extraction
The extraction pipeline (cam.core.services.extraction) turns unstructured documents into structured domain-model fields.
Text layer
- Native PDFs:
pdfplumber/ PyMuPDF for text extraction - Scans/images: Tesseract OCR fallback
- Structured forms: Direct field mapping where templates are known
Structuring
An LLM (via LLMStructuringClient) maps raw text → domain model using the extraction_schema prompt. Returns per-field confidence scores.
fields = await extractor.extract(
document_bytes,
profile=MappingProfile(
fields=[
ExtractedField(name="full_name", type="str", required=True),
ExtractedField(name="date_of_birth", type="date", required=True),
ExtractedField(name="a_number", type="str", pattern=r"A\d{8,9}"),
]
)
)Extraction source tracking
Each extracted field records its provenance via ExtractionSource:
class ExtractionSource(str, Enum):
TEXT = "text" # native PDF text extraction
OCR = "ocr" # Tesseract OCR fallback
LLM = "llm" # LLM structuring path
FORM_MAP = "form_map" # structured form field mappingEvery ExtractedField carries a source: ExtractionSource so downstream consumers know exactly how the value was obtained. This drives both the deterministic flag and QC extraction_confidence check behaviour.
Low-confidence gate
Fields with confidence < threshold (default 0.80) are flagged for human verification before they enter a system of record. This feeds into the QC extraction_confidence check.
Page-coverage invariant
Every extraction produces a PageCoverage report:
class PageCoverage(BaseModel):
total_pages: int
text_pages: list[int] # native text extracted
ocr_pages: list[int] # OCR fallback applied
skipped_pages: list[int] # explicitly unprocessable, surfaced
# INVARIANT: sorted(text ∪ ocr ∪ skipped) == [1..total_pages] (disjoint)No page is silently dropped. If a page cannot be processed, it appears in skipped_pages with a reason. The caller knows exactly what was covered and what was not.
Determinism flag
The ExtractionProposal carries a deterministic: bool flag:
True— only text/OCR paths were used; output is deterministic and idempotentFalse— the LLM structuring path was invoked; output may vary between runs
This flag enables downstream consumers (QC checks, workflows) to adjust their behaviour based on extraction reliability.
Residency guard in detail
The ResidencyGuard wraps the LLMStructuringClient and performs these checks before any inference call:
- Is
CAM_EXTRACTION_ALLOW_EXTERNAL_INFERENCEset totrue? - Is the target endpoint in
EXTRACTION_RESIDENCY_ALLOWLIST? - Does the request data satisfy the configured residency region?
If any check fails, the guard blocks the call and the pipeline falls back to rule-based / regex extraction. The guard logs the block reason but never crashes the pipeline.
Extraction configuration
| Variable | Default | Purpose |
|---|---|---|
CAM_EXTRACTION_ALLOW_EXTERNAL_INFERENCE | false | Enable/disable external LLM calls |
EXTRACTION_PROVIDER | — | LLM provider selection + credentials (from secret store) |
EXTRACTION_RESIDENCY_ALLOWLIST | — | Permitted endpoints/regions for inference |
EXTRACTION_DEFAULT_THRESHOLD | 0.80 | Confidence threshold for low-confidence flagging |
EXTRACTION_OCR_LANGS | eng | Tesseract language packs |
EXTRACTION_MAX_PAGES | 500 | Maximum pages per extraction |
EXTRACTION_MAX_BYTES | 50 MiB | Maximum input size |
Residency guard
If CAM_EXTRACTION_ALLOW_EXTERNAL_INFERENCE=false (default), the ResidencyGuard blocks any call that would send client data to an external LLM provider. The extraction pipeline falls back to rule-based / regex extraction.
The LLMStructuringClient interface is implemented as a MockLLMClient for testing. Swapping to Anthropic Claude or another provider requires only implementing the interface — no workflow code changes.
Service container
All core services are accessed through a ServiceContainer that is injected into workflow handlers:
@dataclass
class ServiceContainer:
audit: AuditService
deadline: DeadlineService
qc: QCService
extraction: ExtractionService
document_gen: DocumentGenerationService
notification: NotificationService
connectors: ConnectorRegistryThe container is created once at startup and passed to register_intake_workflow(services), register_status_update_workflow(services), etc. This replaces the deprecated module-level _services singleton pattern.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Using timedelta(days=n) for business-day offsets | Counts calendar days, not business days — deadlines land on weekends/holidays | Always use Calendar.add_business_days() |
| Adding a QC check without registering it | Check is defined but never runs | Call register_check(MyCheck()) at startup |
Setting CAM_EXTRACTION_ALLOW_EXTERNAL_INFERENCE=true in production | Client data sent to external LLM provider | Keep default false; only enable with explicit data residency approval |
| Skipping the dead-man's-switch check | Scheduler outage goes undetected — missed deadlines | The switch is always-on; do not disable it |
Verification
After reading this page, verify your understanding:
- Explain what happens when the deadline engine encounters missing holiday data for a year
- Describe the QC verdict model: what single verdict causes an aggregate
block? - Show how to add a custom QC check without editing registry core
- Explain what
[[MISSING: label]]means in a generated document
Next steps
- Document Generation Workflow — End-to-end template → DOCX/PDF
- Document Routing Workflow — Classification, filing, and privilege gates
- Client Intake Workflow — Extraction in practice: PDF → structured matter
- Connector Layer — How services interact with external systems
Last updated: 2026-06-01