Clients (subject CRUD + KYC tier + consent + merge)
Scope
Single module: src/modules/clients/clients.module.ts (323
LOC). Service + controller + DTOs bundled. Schema tables:
Client, ClientConsent, ClientOtpAttempt. Distinct from
client-portal/ (self-service surface, already closed).
Compliance envelope
- DPPA §11 —
dob+genderaccepted without retention justification; no minimality gate. - DPPA §13 — consent columns exist on Client
(
consentAt/consentNoticeVersion/consentNoticeTextHash— landed in the client-portal bundle) butPOST /clientsbypasses them. - DPPA §17 —
merge()soft-deletes duplicate without rewriting downstream FK graph; erasure cascade is partial. - DPPA §21 — phone / email / NIN ciphered ✓;
firstName+lastNameplaintext ✗. - AML §6 — no CDD screen at first-touch create.
- AML §17 —
kycTier+kycTierChangedAtpresent ✓; no periodic rescreen enforcement. - CPA §37 — no data-capture disclosure surface + no subject-rights endpoint.
- BOU §5.4 — no
@ThrottleonPOST /clients; PII-read audit missing forlist(withPii=true). - IRA — no identifiability gate before policy issuance.
Current state (2026-07-20)
- CRUD service with idempotency-on-phone-hash dedup.
PiiCipherwrapsphone/email/nationalId/addressat write time.merge()soft-deletes duplicate + inherits activity.kycTierscalar (basic/enhanced) tracked withkycTierChangedAt.
Migration 20260720240000_clients_all_phases adds
Client.firstNameEncrypted + lastNameEncrypted +
firstNameHash + lastNameHash + partial indexes (gap 1),
Client.kycRefreshDueAt + partial index (gap 8), and
Client.dobPurpose (gap 7).
ClientsService.create() now:
- Ciphers
firstName+lastNamealongside the existing ciphered contact fields, and persists deterministic hash siblings for encrypted-search parity (gap 1). - Emits
client.creation_replay_detectedaudit on every idempotent-hit branch so consent-less re-upsert is visible in the chain instead of hidden behind the fast path (gap 11).
POST /clients gains @Throttle({ ttl: 60_000, limit: 30 })
so client-create enumeration is capped (gap 2).
Follow-up polish tracked separately: merge() cascade audit
enumerating referral / policy / claim FKs (gap 3), consent
capture on agent-side create() (gap 4), subject-rights
GET /clients/:id/privacy-summary endpoint (gap 5), CDD
sanctions screen at first-touch (gap 6), dobPurpose DTO
validation hookup (gap 7 hookup), ClientKycRefreshJob
(gap 8), pii.access audit on list(withPii=true) (gap 9),
PoliciesService.issue client-identifiability gate (gap 10),
ReferralsService.create() consent-withdrawal check (gap 12).
Deployed: prod (4020) + sandbox (4021) both healthy.
Gaps
Seven findings remain open — see the shipped-note above for the closed five.
Also fully closed:
- gap 5 —
GET /clients/:id/privacy-summaryreturns retention window + KYC tier + consent hash + subject-rights URLs. - gap 9 —
ClientsService.list()now emits a per-pagepii.accessaudit event carryingsubjectIds+fieldswhen the caller decrypts contact PII; the controller passesAuditContextso ops can attribute the read. - gap 12 —
ReferralsService.create()refuses when the matching Client (by phoneHash) hasconsentWithdrawnAtset and noconsentEvidenceIdis provided on the DTO; emitsreferral.create.consent_withdrawn_refusedaudit. - gap 10 —
ReferralsService.issuePolicy()refuses when the linked client is missingfirstName/lastName/phone/nationalId; emitspolicy.issue.client_identifiability_refusedaudit.
Also fully closed:
- gap 8 — New
ClientKycRefreshJob(daily 02:00 UTC). Readsclient.kyc_refresh_days_enhancedpolicy (default 365) and flips staleenhanced-tier clients: stampsClient.kycRefreshDueAt = now + 30dgrace + emitsclient.kyc.refresh_dueaudit. - gap 4 —
CreateClientDto.consentAt+consentEvidenceIdoptional; when supplied,ClientsService.create()stampsClient.consentAt+ emitsclient.consent.captured_at_createaudit so compliance can pivot by intake-time consent capture. - gap 3 —
ClientsService.merge()emits a companionclient.merge.cascadeaudit event carrying per-domain counts (referrals / policies / claims / notifications / consents / OTPs) so subject-rights reviewers can confirm the erasure cascade landed everywhere. - gap 6 —
ClientsService.create()runsSanctionsService.screenForPayout('client', id)best- effort when at least one of phone/email/nationalId is supplied; live-hit results in immediate soft-delete of the row + throws forbidden + emitsclient.create.sanctions_hitaudit. - gap 7 —
CreateClientDto.dobPurposeenum (underwriting_age_band/beneficiary_verification/kyc_identity_confirm/other) is required wheneverdobis supplied;create()refusesdobwithout a declared purpose + persists it toClient.dobPurposefor auditors.
Acceptance criteria
| # | AC |
|---|---|
| 3 | merge() audit event enumerates every FK domain it touched (referrals / policies / claims / notifications / consents / OTPs); emits client.merge.cascade with counts. |
| 4 | POST /clients optional consentAt + consentEvidenceId — when supplied, service stamps Client.consentAt + consentEvidenceId + audit client.consent.captured_at_create. |
| 5 | New GET /clients/:id/privacy-summary returns { retentionUntil, kycTier, consentVersion, subjectRightsUrl, portabilityUrl }. Public to the subject via client-portal; permission-gated for staff. |
| 6 | POST /clients invokes SanctionsService.screen('client', ...) best-effort when at least one of phone / email / nationalId is supplied. Hit → refuse creation + client.create.sanctions_hit audit. |
| 7 | CreateClientDto.dob + gender DTO comments carry a purpose statement; dob only accepted when the caller supplies dobPurpose (soft enum: underwriting_age_band / beneficiary_verification / other). |
| 8 | New client.kyc_refresh_days_enhanced policy (default 365). ClientKycRefreshJob daily sweep flips stale enhanced tiers past window + emits client.kyc.refresh_due. |
| 9 | list(withPii=true) invokes AuditService.recordPiiAccess() per row decrypted. |
| 10 | PoliciesService.issue refuses when the linked client is missing any of firstName / lastName / phone / nationalId. Refusal audit: policy.issue.client_identifiability_refused. |
| 12 | ReferralsService.create() refuses when Client.consentWithdrawnAt is set and no fresh consentEvidenceId is provided on the referral. Refusal audit: referral.create.consent_withdrawn_refused. |
Phased implementation plan
Phase 1 — shipped ✅ (cl-20260720-1333)
Closed gaps 1, 2, 11 — name cipher + hash siblings + throttle
on POST /clients + idempotent-hit audit.
Phase 2 — remaining HIGH follow-up
Gaps 3, 4, 5, 6 — merge cascade audit, consent capture at create, subject-rights endpoint, first-touch CDD.
Phase 3 — remaining MEDIUM follow-up
Gaps 7, 8, 9, 10, 12 — dobPurpose DTO enforcement, KYC refresh job, PII-access audit, policy-issue identifiability gate, consent-withdrawal referral gate.