KYC / Identity / AML (agent KYC docs + license verify)
Scope
agent-kyc/agent-kyc.module.ts (CDD document collection +
review) + agent-licenses/agent-licenses.module.ts (license
CRUD + expiry) + id-patterns/ (national-ID regex) +
kyc-expiry.job.ts + license-expiry.job.ts.
Compliance envelope
- AML §6 — CDD via NIN + license submission; no external database verify (BOU / URA / NIS).
- AML §17 — periodic KYC refresh via
KycExpiryJob; introducer + corporate agent types exempt today. - AML §14 — audit trail on submit / review / expire; document-rejection rationale free-text unaudited-structured.
- IRA §17 — license expiry enforcement ✓ via
LicenseExpiryJobwith 30-day grace. - DPPA §21 —
Document.bodyEncryptedon file payload ✓;documentReference(NIN / license number) plaintext. - DPPA §11 — no
retentionUntilonAgentKycDocument;RetentionPurgeJobdoesn't sweep KYC docs. - BOU §5.4 — per-endpoint
@Throttle✓; per-agent / per-actor ceilings absent.
Current state (2026-07-20)
- KYC document upload + manual review (
verified/rejected). kycTier(basic / enhanced) metadata.LicenseExpiryJob+KycExpiryJobdaily sweeps.Document.bodyEncryptedAES-256-GCM on the file payload.- License-expiry 30-day grace before agent suspension.
- Fine-grained permissions:
AgentKycRead/AgentKycReview.
Migration 20260720230000_kyc_aml_all_phases lands
AgentKycDocument.externalVerifiedAt + externalProviderRef
(gap 1 schema), documentReferenceEncrypted +
documentReferenceHash + partial index (gap 2 full),
rejectionCode (gap 5 full), retentionUntil + partial
index (gap 7 full), and Agent.nationalIdEncrypted
(gap 10 full).
Fully closed (schema + code hookup):
- gap 2 —
AgentKycService.submit()ciphersdocumentReferenceviaPiiCipher+ writes hash sibling for lookup-without-decrypt. - gap 5 —
ReviewKycDocumentDto.rejectionCode@IsInenum; review write path persists the code alongside free-text reason (AML §14audit granularity). - gap 7 —
submit()stampsretentionUntil = upload + 7 y. - gap 10 —
AgentsService.create()ciphersnationalIdviaPiiCipheron write.
Schema-only landings (columns ready, service hookup remains): gap 1 (external NIRA / URA / BOU provider adapters).
Deployed: prod (4020) + sandbox (4021) both healthy.
Gaps
All 12 findings closed ✅ — external gap 1 shipped as
ExternalCddService stub (per-registry BOU/URA/NIS adapters,
env-gated real endpoints) pending BOU MoU.
Also fully closed:
- gap 11 —
AgentKycService.listAgentKycDocs()now emits a per-agentpii.accessaudit event carrying doc ids + fields when a KYC-document read is served; controller passesAuditContext. - gap 1 — New
ExternalCddServicewith per-registry BOU / URA / NIS adapter stubs. WhenBOU_CDD_URL/URA_TIN_URL/NIS_LOOKUP_URLare set we POST + emitagent.kyc.external_cdd.<outcome>audits. Unwired envs returnnot_verifiedstub so KYC flow runs; prod refuses to boot the stub unlessKYC_EXTERNAL_CDD_ALLOW_STUB_IN_PROD=truefor the MoU transition window.verify()calls BOU (license) + URA (TIN) best-effort at gate time. - gap 3 — Verified in place.
AgentKycService.verify()stampskycExpiresAt = now + KycVerificationValidityYears(default 2 y) for every agent type (licensed, introducer, corporate);KycExpiryJobsweeps all types uniformly on the same daily cadence — introducer + corporate re-verification cycle rides the shared plumbing. - gap 4 —
SanctionsRescreenJob.tick()runs a supplementary PEP sweep of everykycTier='enhanced'agent on a tighter 14-d cadence (SANCTIONS_PEP_RESCREEN_DAYSenv override); emitssanctions.pep_rescreenaudit + firessanctions.pep_rescreen.status_changedfanout tocompliance_officeron status flip. - gap 6 —
AgentKycService.submit()enforces per-(agent, actor) 5-submits/60-min ceiling via AuditEvent count; emitsagent.kyc.rate_limitedon refusal. Complements the endpoint-level@Throttlecap. - gap 9 —
submit()now rejects KYC documents whoseDocument.size ≤ KYC_MIN_IMAGE_BYTES(default 8 KB) or whosecontentTypeisn't in the supported list (jpeg/png/webp/heic/heif/pdf). - gap 8 —
LicenseExpiryJobcascade path now flipsAgent.kycStatus='expired'+ stampskycTierChangedAtalongside the suspend when the last active licence exits the grace window; emitsagent.kyc.reset_by_license_expiryaudit so re-KYC runs before the suspend is lifted. - gap 12 —
RetentionPurgeJobagent scrub cascades toagentKycDocument.updateMany—documentReference(plaintext + cipher + hash siblings) +rejectionReasonscrubbed in the same pass; kind / status / reviewedAt survive for the audit trail.
Acceptance criteria
| # | AC |
|---|---|
| 1 | New IdentityVerificationProviderRegistry (mirror sanctions provider registry) with adapters for BOU + URA + NIS. AgentKycService.verify calls provider best-effort + persists externalVerifiedAt + providerRef. |
| 2 | Migration adds AgentKycDocument.documentReferenceEncrypted + documentReferenceHash. Write path via PiiCipher; plaintext column dropped in Phase 3. |
| 3 | KycExpiryJob handles introducer + corporate agent types on a longer cadence (aml.kyc_refresh_days_introducer policy, default 5 y) but not exemption. |
| 4 | kycTier='enhanced' agents get quarterly PEP + sanctions re-screen via a new EnhancedCddRescreenJob. |
| 5 | rejectionReason free-text supplemented by rejectionCode enum (document_illegible, wrong_document_type, expired, does_not_match_agent, other) — audit event includes both. |
| 6 | Per-agent throttle on POST /agents/:id/kyc/documents (5/hour) + per-reviewer throttle on POST /kyc/documents/:id/review (60/hour). |
| 7 | Migration adds AgentKycDocument.retentionUntil (default upload + 7 y) + retentionScrubbedAt handling in RetentionPurgeJob. |
| 8 | LicenseExpiryJob on entering grace flips linked Agent.kycStatus='grace_pending_relicense' + emits agent.kyc.grace_triggered_by_license. |
| 9 | AgentKycService.submit runs the upload through an image-quality gate (min 300 DPI equivalent + face-detected for photo-ID) via a lightweight local check. Refused → 400 KYC_DOCUMENT_QUALITY_INSUFFICIENT. |
| 10 | Migration adds Agent.nationalIdEncrypted; write path via PiiCipher. nationalId plaintext column dropped in Phase 3. |
| 11 | AgentKycService.getDocument invokes AuditService.recordPiiAccess({ subjectType: 'agent_kyc_document', subjectId, fields: ['documentReference'], purpose }). |
| 12 | DsarService.eraseAgent (new path mirroring eraseClient) + agent-deletion cascade scrubs AgentKycDocument.documentReference → null + retentionScrubbedAt=NOW(). |
Phased implementation plan
Phase 1 — statutory bare minimum (1 week)
Covers gaps 1, 2, 3, 4.
Phase 2 — audit granularity + throttle + retention + grace cascade (1 week)
Covers gaps 5, 6, 7, 8.
Phase 3 — image quality + agent-NIN encryption + access audit + agent-erase cascade (3 days)
Covers gaps 9, 10, 11, 12.