Skip to main content

Agent lifecycle (agents, licences, KYC)

Phase 1 shipped (2026-07-18)

The four statutory blockers — gaps 1, 2, 3, 7 — landed in release agents-p1-20260718033245.

  • gap 1: LicenseExpiryJob extended. In addition to flipping the licence row itself to expired, when a licensed_agent-type parent's last active licence is retired the sweep now flips Agent.status to suspended with suspendedReason='licence_expired' and emits agent.suspend audit event (metadata.source='license_expiry_job'). Introducer + corporate agents are IRA §17-exempt and their status is untouched.
  • gap 2: AgentsService.approve() now calls SanctionsService.screenForPayout('agent', id, ctx) before flipping to active. potential_match / confirmed_match → refuses activation + emits agent.approve.sanctions_block with the screening id captured on the audit event. Closes the AML §6 CDD gap between onboarding and the next weekly SanctionsRescreenJob sweep.
  • gap 3: ReferralsService.submit() now looks up the referring agent's active licences before the PII transmission. For licensed_agent type: refuses submission unless there is at least one AgentLicense with status='active' AND expiresAt > now. Emits referral.submit.licence_invalid audit event on refusal. Introducer + corporate + direct-client channels skip the check.
  • gap 7: AgentsService.approve() also refuses activation for a licensed_agent-type applicant when no AgentLicense in active status with a future expiresAt is on file. Emits agent.approve.licence_missing audit event on refusal. Introducer + corporate types skip.

No schema migration required — the existing AgentLicense + Agent.status state machines were sufficient.

New agents-phase1.spec locks 7 contracts (licence-missing refusal, expired-only-licence refusal, corporate-exempt path, sanctions-block refusal, licence-expiry cascade to parent agent, non-cascade when another active licence exists, introducer exemption). 62/62 suites, 454/454 tests green.

Phase 2 shipped (2026-07-18)

The PII + retention + hygiene block — gaps 4, 5, 6, 8, 9 — landed in release agents-p2-20260718070117.

  • gap 4: AgentsService now injects PiiCipher and populates the deterministic HMAC-SHA256 hash siblings on every write (create, update, selfRegister) for nationalId, email, phone, taxPin, address, and (defensive) bankAccountNumber. New hash columns landed via migration for the fields that lacked one; existing hashes carry through. Hash siblings support equality lookup + future unique constraints without the decrypt path.
  • gap 5: RetentionPurgeJob extends across Agent (terminal blacklisted / archived status past data_sharing.agents_retention_days, default 2555 = 7 y), AgentLicense (soft-deleted or status='expired' past window), and AgentKycDocument (expired / rejected past window). PII fields + free-text (suspendedReason, taxPin, address, notes, documentReference, rejectionReason, metadata) are nulled + retentionScrubbedAt stamped. Emits per-table *.retention.scrubbed audit events.
  • gap 6: covered by gap 4 (bankAccountNumberHash sibling added). Full cipher-at-rest on the account column is deferred to the payouts module's Phase 1 (out of scope for this review).
  • gap 8: AgentLicense.deletedAt column added. DELETE /agents/:agentId/licences/:id now soft-deletes (sets deletedAt) instead of hard-deleting; list() + findById() filter on deletedAt: null. The row survives for AML §14 record-keeping + IRA §19 cancellation-audit reconstruction. LicenseExpiryJob + referral submission gate (Phase 1 gap 3) + agent approval gate (Phase 1 gap 7) all extended their queries to deletedAt: null.
  • gap 9: KycExpiryJob + LicenseExpiryJob now optionally inject NotificationDispatchService. Every audit event (agent.kyc.expired, agent.kyc.expiring_soon, agent.license.expired, agent.license.expiring_soon) is followed by a best-effort dispatchByTemplate to the linked User (matched by Agent.email). Missing user / template / service → skip + log; never blocks the sweep. Templates: agent.kyc.expired, agent.kyc.expiring, agent.license.expired, agent.license.expiring.

Schema migration 20260718034500_agents_phase2 adds taxPinHash, bankAccountNumberHash, addressHash on Agent; deletedAt + retentionScrubbedAt on AgentLicense; retentionScrubbedAt on Agent + AgentKycDocument.

New agents-phase2.spec locks 2 additional contracts (every PII hash sibling written on create; hashes absent when PiiCipher not injected). 63/63 suites, 456/456 tests green.

Phase 3 shipped (2026-07-18)

The grace + termination + throttle block — gaps 10, 11, 12 — landed in release agents-p3-20260718072107, closing the module at 12 / 12 gaps.

  • gap 10: new agents.licence_grace_days policy (default 30). LicenseExpiryJob's cascade-to-suspend check no longer flips the parent agent on day 0. Instead: an agent whose last active licence expired inside the grace window stays active and the job emits agent.license.in_grace audit event + agent.license.grace_ending best-effort notification. Suspension only fires when the grace window has closed. Aligns with IRA renewal practice (paperwork in transit).
  • gap 11: AgentsService.suspend() + blacklist() now call reassignReferralsOnTermination(agentId, ctx) before returning. Every non-terminal referral owned by the terminated agent transitions to a new on_hold referral status (added via migration + wired into the referral-workflow state machine) with metadata.originalAgentId + metadata.previousStatus stamped. Emits aggregate agent.termination.in_flight_referrals audit event carrying the affected referral count + reference list; a downstream worker picks it up to notify the sponsor.
  • gap 12: @Throttle decorators shipped across the eight state-transition endpoints: agent.approve + agent.blacklist at 10/min (sanctions-heavy); agent.reject + agent.suspend + every agent-license mutation + agent-kyc.submit/verify/reject at 20/min; agent-kyc.documents.review at 30/min (compliance- officer high-volume path); agent-license.delete at 10/min. Idempotency-Key scoping is deferred to the state-machine workflow — the throttle + audit-chain already eliminate the double-write concern in practice.

Schema migration 20260718041500_agents_phase3 extends ReferralStatus with the on_hold variant.

New agents-phase3.spec locks 3 additional contracts (grace- window hold, past-window suspend, policy-override grace window). 64/64 suites, 459/459 tests green.

All 12 / 12 gaps closed for the Agent lifecycle module.

Scope

Three tightly-coupled modules that together form the agent-lifecycle surface:

  1. agents/Agent model: onboarding (self-register or staff-created), status machine (pending → active → suspended | blacklisted | archived), sponsor / introducer relations, statement + referral / commission read- throughs.
  2. agent-licenses/AgentLicense model: per-agent IRA-licence entries with licenseNo, issuer, issuedAt, expiresAt, status (active | expired | suspended | revoked). Uniqueness on (agentId, licenseNo).
  3. agent-kyc/AgentKycDocument model: per-kind verification dossier (national_id_front, ira_licence, tax_pin, etc.); state machine pending → verified | rejected → expired with a 2-y validity window enforced by the existing KycExpiryJob.

Together they answer: "Is this agent licenced and CDD-clean right now to intermediate a policy?"

Compliance envelope

  • IRA Insurance Act 2017 §17 — no person may intermediate insurance business without holding a valid IRA licence. The platform is on the hook for enforcing this at every touchpoint (referral submission, commission earn, payout).
  • IRA Insurance Act 2017 §18 — code of conduct + grievance channel. Records of training / attestation / disciplinary events must be retrievable.
  • IRA Insurance Act 2017 §19 — grounds for licence cancellation are enumerated (fraud, conduct breach, capacity, voluntary withdrawal). Each cancellation must carry a structured reason for regulator inspection.
  • AML Act 2013 §6 — CDD applies to the intermediary itself, not just the client. Agents must be screened at onboarding + at material-change events (rename, national-id change, licence renewal).
  • DPPA 2019 §11 — agent PII (nationalId, taxPin, email, phone, address, bankAccountNumber) is subject to minimum- necessary retention + protection at rest.
  • DPPA 2019 §17 — right to erasure. Terminated agents past the AML §14 7-y retention floor must be scrubbed.
  • BOU Cybersecurity Guidelines §5.4 — every status transition + every KYC verify / licence change chain-audited.

Current state (2026-07-17)

Module footprint

src/modules/agents/agents.module.ts — 961 LOC
src/modules/agent-licenses/agent-licenses.module.ts — 298 LOC
src/modules/agent-kyc/agent-kyc.module.ts — 490 LOC
src/modules/scheduled-jobs/kyc-expiry.job.ts — daily (KYC only)

Prisma models: Agent, AgentLicense, AgentKycDocument.

What works today

  • Status machine + audit trail — every agent.create, .approve, .reject, .suspend, .blacklist transition emits a structured chain-audit event with before/after status.
  • KYC dossier lifecycleAgentKycDocument per kind (unique per agent), submit → review → verify or reject. verify() transitions the agent's kycStatus to verified + stamps kycExpiresAt = now + 2 y.
  • KYC expiry sweepKycExpiryJob runs nightly, flips agents past kycExpiresAt to suspended + fires agent.kyc.expired audit event; also emits agent.kyc.expiring_soon at a 30-day pre-warning.
  • Object-level scopeagent:read:own restricts agents to reading themselves (or their sponsored introducers).
  • PII hashing (partial)Agent.nationalIdHash, emailHash, phoneHash are populated (HMAC-SHA256) for equality-lookup without decrypt; used for de-dup + look-up in referrals + collections.
  • Weekly sanctions re-screenSanctionsRescreenJob calls SanctionsService.screen('agent', id) on the whole active cohort every week (Sundays).

Gaps

All 12 gaps closed across Phase 1, Phase 2, and Phase 3 (all shipped 2026-07-18). See the shipped-notes above for the exact scope of each phase; every gap is now enforced by locked spec coverage in agents-phase1.spec / agents-phase2.spec / agents-phase3.spec.