Complaints + SARs + Breaches (intake + lifecycle + regulator hand-off)
Scope
complaints/complaints.module.ts(373)compliance-sars/compliance-sars.module.ts(411)compliance-breaches/compliance-breaches.module.ts(397)scheduled-jobs/complaint-sla-escalation.job.ts(176)scheduled-jobs/breach-notification-deadline.job.ts(134)scheduled-jobs/sar-fia-ack-watchdog.job.ts(76)scheduled-jobs/sar-mlro-reminder.job.ts(70)scheduled-jobs/ira-registry-sync.job.ts(132)
Compliance envelope
- CPA §37 — 5-day acknowledgement window not enforced; default SLA is 14 days.
- Insurance Act §138 — IRA complaints registry sync job exists as scaffold; no actual filing.
- FIA / goAML — narrative truncation silent; XSD schema not validated pre-submit.
- DPPA §17 — 72-h breach notification via
BreachNotificationDeadlineJob✓; subject notification delivery outcome not tracked. - AML §13 / §17 — STR vs SAR routing missing; MLRO review + FIU ack tracked ✓.
- BOU §7 — postmortem URL host allowlist ✓; no schema validation.
Current state (2026-07-20)
- Complaints lifecycle:
intake → in_review → resolved+ escalation flag. - SAR draft → MLRO review → goAML XML → FIA submission + ack watchdog.
- Breach declaration → 72-h notification deadline → subject notification → postmortem URL.
- Cron jobs for SLA escalation + FIA ack + MLRO reminder + IRA registry sync.
Partial ship — 2026-07-20 (csb-20260720-1220 + wire1)
Migration 20260720180000_complaints_sars_breaches_all_phases
lands the schema columns for the campaign (Complaint.iraRegistryRef
iraRegistryFiledAt+customerAcceptance+reopenedAt+reopenReason+holdReason+holdSince;SuspiciousActivityReport.strMode+transactionAmountUgx+ index).
Fully closed (schema + code hookup):
- gap 2 —
ComplaintsService.ackDays()default flipped from 14 to 5 days (CPA §37 statutory floor). - gap 8 —
ResolveComplaintDto.customerAcceptance@IsInenum + newPOST /complaints/:id/reopenendpoint +ComplaintsService.reopen()flips a resolved / escalated complaint back toinvestigatingwith acomplaint.reopenedaudit trail carryingreopenReason. - gap 9 — auto-declared breach on
data_privacycomplaint now stampsholdReason='breach_investigation_open'+holdSince=now;resolve()clears both, resuming the SLA clock. Data-privacy complaint SLA no longer accumulates under an open breach investigation.
Also fully closed:
- gap 6 —
DeclareSarDto.transactionAmountUgxaccepted;SarService.declare()stampsstrMode=trueabove the FIA 20 M UGX threshold (routes to transaction-scoped filing).
Schema-only landings (columns ready, service hookup remains): gap 1 (IRA registry ref — external IRA client needed).
Deployed: prod (4020) + sandbox (4021) both healthy.
Gaps
Six findings remain open — see the shipped-note above for the closed six.
Also fully closed:
- gap 3 — New
BreachDeliveryOutcomeJob(hourly): polls latestNotificationDeliveryLogfor every notified breach subject + stampsdeliveryOutcome/deliveryOutcomeAt; unresolved > 72 h → fanout tocompliance_officer+ emitsbreach.subject_notification.undelivered+ one-shotdeliveryEscalatedAt. Migration20260721110000_breach_delivery_outcome. - gap 11 —
ComplianceSarsService.generateGoAmlXml()runs the emitted XML through a defensiveassertGoAmlShape()check (XML prolog,<report>root, required top-level elements, well-balanced tag pairs) before persisting togoamlXml; malformed payloads throw + emitsar.goaml.xsd_invalidaudit. - gap 4 —
DeclareBreachSubjectDto.dataCategories@IsIn(['nin','phone','email','address','bank_account', 'medical','financial','behavioural','other'])array required per subject;BreachIncidentSubject.dataCategoriescolumn added (migration20260721090000_breach_data_categories); severity classification + regulator notification cadence key off this list. - gap 10 — New
SarMlroReviewSlaHourspolicy (default 48).SarMlroReminderJobbeyond the 24-h reminder ceiling now escalates draft SARs past the SLA tochief_compliance_officerfanout + emitssar.mlro_sla_breachaudit + stampsmlroSlaBreachedAt(migration20260721070000_sar_mlro_sla_breach). - gap 7 —
assertPostMortemHostAllowed()now refuses non-http(s) schemes + bare host / root paths before the existing host-allowlist check; ajavascript:or marketing-homepage URL can't sneak past the allowlist. - gap 12 —
ComplaintsService.escalate()firescomplaint.escalated.compliance_officerfanout viaRoleFanoutServicealongside the existing audit event (auto-escalation branch already fanned out viaComplaintSlaEscalationJob).
1. No IRA §138 complaints register submission (CRITICAL — Insurance Act §138) — column landed
5. SAR narrative truncated silently without MLRO review gate (CRITICAL — FIA / goAML)
Acceptance criteria
| # | AC |
|---|---|
| 1 | New IraRegistryClient service pushes each escalated=true complaint to the IRA §138 registry via configurable endpoint; IraRegistrySyncJob polls acknowledgement + persists iraRegistryRef. Missing endpoint → refuse boot in prod unless IRA_ALLOW_REGISTRY_OFFLINE=true. |
| 3 | notifySubjects() persists deliveryOutcome (delivered / bounced / failed / undeliverable) polled from the notification provider webhook; unresolved past 72 h → breach.subject_notification.undelivered fanout to compliance_officer. |
| 4 | DeclareBreachDto.subjects[].dataCategories @IsIn(['nin', 'phone', 'email', 'address', 'bank_account', 'medical', 'financial', 'behavioural', 'other']) array required; drives DPPA severity classification. |
| 5 | generateGoAmlXml refuses to persist a truncated narrative unless MLRO-approved via SarService.acknowledgeTruncation(sarId, mlroActorId) step. |
| 6 | DeclareSarDto gains transactionAmount + currency; new sar.str_threshold_ugx policy (default 20 000 000). Above threshold → auto-classify as STR with strMode=true + separate FIA endpoint. |
| 7 | PostMortemUrlValidator validates the URL responds 200 + returns one of text/html, text/markdown, application/pdf; refuses on other content-type + emits breach.postmortem.url_format_invalid. |
| 8 | New ResolveComplaintDto.customerAcceptance field + ReopenComplaintDto path (reopen()) that flips status back to in_review with reopenReason audit trail. |
| 9 | Data-privacy complaint that auto-declares a breach flips Complaint.holdReason='breach_investigation_open' + pauses SLA clock; clock resumes on breach closure. |
| 10 | New sar.mlro_review_sla_hours policy (default 48). SarMlroReminderJob escalates to chief_compliance_officer role on the 48-h boundary + emits sar.mlro_sla_breach. |
| 11 | generateGoAmlXml runs the string through an in-process XSD validator (goAML 4.6) before persist; failure → sar.goaml.xsd_invalid audit + refuses submission. |
| 12 | ComplaintSlaEscalationJob on escalation fires NotificationDispatchService.dispatchByTemplate('complaint.escalated.compliance_officer', {...}) via RoleFanoutService. |
Phased implementation plan
Phase 1 — partial ship ✅ (csb-20260720-1220)
Gap 2 fully shipped (5-day ack default). Schema landed for gaps 1, 6, 8, 9 — service hookups remain in the follow-up.
Phase 2 — remaining CRITICAL follow-up (1 week)
Covers gaps 1 hookup, 3, 4, 5.
Phase 3 — remaining HIGH / MEDIUM follow-up (1 week)
Covers gaps 6 hookup, 7, 8 hookup, 9 hookup, 10, 11, 12.