Agent lifecycle (agents, licences, KYC)
The four statutory blockers — gaps 1, 2, 3, 7 —
landed in release agents-p1-20260718033245.
- gap 1:
LicenseExpiryJobextended. In addition to flipping the licence row itself toexpired, when alicensed_agent-type parent's last active licence is retired the sweep now flipsAgent.statustosuspendedwithsuspendedReason='licence_expired'and emitsagent.suspendaudit event (metadata.source='license_expiry_job'). Introducer + corporate agents are IRA §17-exempt and their status is untouched. - gap 2:
AgentsService.approve()now callsSanctionsService.screenForPayout('agent', id, ctx)before flipping toactive.potential_match/confirmed_match→ refuses activation + emitsagent.approve.sanctions_blockwith the screening id captured on the audit event. Closes the AML §6 CDD gap between onboarding and the next weeklySanctionsRescreenJobsweep. - gap 3:
ReferralsService.submit()now looks up the referring agent's active licences before the PII transmission. Forlicensed_agenttype: refuses submission unless there is at least oneAgentLicensewithstatus='active' AND expiresAt > now. Emitsreferral.submit.licence_invalidaudit event on refusal. Introducer + corporate + direct-client channels skip the check. - gap 7:
AgentsService.approve()also refuses activation for alicensed_agent-type applicant when noAgentLicenseinactivestatus with a futureexpiresAtis on file. Emitsagent.approve.licence_missingaudit 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.
The PII + retention + hygiene block — gaps 4, 5, 6,
8, 9 — landed in release agents-p2-20260718070117.
- gap 4:
AgentsServicenow injectsPiiCipherand populates the deterministic HMAC-SHA256 hash siblings on every write (create,update,selfRegister) fornationalId,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:
RetentionPurgeJobextends acrossAgent(terminalblacklisted/archivedstatus pastdata_sharing.agents_retention_days, default 2555 = 7 y),AgentLicense(soft-deleted orstatus='expired'past window), andAgentKycDocument(expired/rejectedpast window). PII fields + free-text (suspendedReason,taxPin,address,notes,documentReference,rejectionReason,metadata) are nulled +retentionScrubbedAtstamped. Emits per-table*.retention.scrubbedaudit events. - gap 6: covered by gap 4 (
bankAccountNumberHashsibling 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.deletedAtcolumn added.DELETE /agents/:agentId/licences/:idnow soft-deletes (setsdeletedAt) instead of hard-deleting;list()+findById()filter ondeletedAt: 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 todeletedAt: null. - gap 9:
KycExpiryJob+LicenseExpiryJobnow optionally injectNotificationDispatchService. Every audit event (agent.kyc.expired,agent.kyc.expiring_soon,agent.license.expired,agent.license.expiring_soon) is followed by a best-effortdispatchByTemplateto the linkedUser(matched byAgent.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.
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_dayspolicy (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 staysactiveand the job emitsagent.license.in_graceaudit event +agent.license.grace_endingbest-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 callreassignReferralsOnTermination(agentId, ctx)before returning. Every non-terminal referral owned by the terminated agent transitions to a newon_holdreferral status (added via migration + wired into the referral-workflow state machine) withmetadata.originalAgentId+metadata.previousStatusstamped. Emits aggregateagent.termination.in_flight_referralsaudit event carrying the affected referral count + reference list; a downstream worker picks it up to notify the sponsor. - gap 12:
@Throttledecorators shipped across the eight state-transition endpoints:agent.approve+agent.blacklistat 10/min (sanctions-heavy);agent.reject+agent.suspend+ everyagent-licensemutation +agent-kyc.submit/verify/rejectat 20/min;agent-kyc.documents.reviewat 30/min (compliance- officer high-volume path);agent-license.deleteat 10/min.Idempotency-Keyscoping 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:
agents/—Agentmodel: onboarding (self-register or staff-created), status machine (pending → active → suspended | blacklisted | archived), sponsor / introducer relations, statement + referral / commission read- throughs.agent-licenses/—AgentLicensemodel: per-agent IRA-licence entries withlicenseNo,issuer,issuedAt,expiresAt,status(active | expired | suspended | revoked). Uniqueness on(agentId, licenseNo).agent-kyc/—AgentKycDocumentmodel: per-kindverification dossier (national_id_front,ira_licence,tax_pin, etc.); state machinepending → verified | rejected → expiredwith a 2-y validity window enforced by the existingKycExpiryJob.
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,.blacklisttransition emits a structured chain-audit event with before/after status. - KYC dossier lifecycle —
AgentKycDocumentperkind(unique per agent), submit → review → verify or reject.verify()transitions the agent'skycStatustoverified+ stampskycExpiresAt = now + 2 y. - KYC expiry sweep —
KycExpiryJobruns nightly, flips agents pastkycExpiresAttosuspended+ firesagent.kyc.expiredaudit event; also emitsagent.kyc.expiring_soonat a 30-day pre-warning. - Object-level scope —
agent:read:ownrestricts agents to reading themselves (or their sponsored introducers). - PII hashing (partial) —
Agent.nationalIdHash,emailHash,phoneHashare populated (HMAC-SHA256) for equality-lookup without decrypt; used for de-dup + look-up in referrals + collections. - Weekly sanctions re-screen —
SanctionsRescreenJobcallsSanctionsService.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.