The loop that wasn't
Our behavioral engine maintains per-account trust scores. When a score drops below a threshold, the account enters a review queue. Our platform already had an hourly cron that scanned this queue and created fraud-alert cases for compliance officers to investigate.
But when the officer finished investigating, confirmed "yes, this is fraud" or "no, false alarm", nothing happened. The engine never learned. The same fraudster could trigger another alert tomorrow, and the same false positive would nag the team again next week.
The feedback we built
When a fraud-alert case is resolved:
TRUE POSITIVE (investigator confirms fraud)
- The account's latest IP is automatically blacklisted in the engine
- The account is marked reviewed (leaves the review queue)
- Every action is audited as
BEHAVIORAL_FEEDBACK
The blacklisted IP means the next transaction from that exact IP is flagged immediately: not when the trust score drops, not on the next hourly scan, but at the moment of screening. Your investigator's judgment protects the next customer in real time.
FALSE POSITIVE (investigator says it's clean)
- The account is marked reviewed (leaves the queue, stops nagging)
- Blacklist entries are NOT auto-cleared
The second point is deliberate. Silently erasing fraud evidence because one officer made a call is exactly what a regulator would challenge. A false positive on the account does not mean the IP is clean, since it might be a shared VPN exit. Removal of blacklist entries is an explicit, audited, human action from the dashboard.
Design rule: true positives produce immediate protective action. False positives stop the noise without erasing evidence. Every decision lands in the audit trail.
Both resolution paths are hooked
We discovered during crosscheck that there are two ways to close a case:
- The maker-checker workflow (propose → approve)
- Direct status update (when maker-checker is disabled)
Our original hook only fired on the first path. Both are now hooked, so the feedback fires regardless of how the case reaches its terminal state.
Blocked transactions feed back too
Cases created from blocked transactions (not just behavioral alerts) now carry the behavioral-account: tag whenever the behavioral engine contributed to the block. This means a TRUE_POSITIVE resolution on a blocked transaction also blacklists the fraudster's IP, even if the block was primarily driven by CBN rules or sanctions.
Blacklist management from your side
The behavioral blacklist was previously read-only. You could see flagged IPs and emails but could not manage them. Now:
- Per-entry Block/Remove buttons on the behavioral dashboard (every action audited)
- Bulk add via a form (IP or email, with validation)
- One-click "Block latest IP" from any account's detail drawer
- "Remove from blacklist" for false-positive recovery (clears the account flag, clears the IP entry, marks it reviewed)
Every action lands in the hash-chained audit trail as BEHAVIORAL_BLACKLIST_UPDATED or BEHAVIORAL_ACCOUNT_UPDATED with the officer's identity.
The architectural lesson
The case module and the engines module have a genuine circular dependency: engine crons create cases, and case resolution feeds back to engines. We initially tried NestJS's forwardRef(), and it failed with UndefinedModuleException across the four-module cycle (engines → case → screening-integrity → watchlist → case).
The clean solution was dependency inversion via a listener registry: CaseService exposes an onCaseResolved(callback) method, and the feedback service registers a listener at boot. No module imports change hands; the graph stays acyclic.
// CaseService: no dependency on engines module
onCaseResolved(listener: (tenantId, actor, resolvedCase) => void): void {
this.resolutionListeners.push(listener);
}
// BehavioralFeedbackService: registers at boot
onModuleInit(): void {
this.caseService.onCaseResolved((...args) => this.onCaseResolved(...args));
}
This is the pattern we would recommend for any NestJS project with a similar "event happens in module A, reaction lives in module B" requirement. It is cleaner than forwardRef webs and testable in isolation.
See a live trace on your own data
We'll run the tracer against a scenario from your institution in a 30-minute demo: blocked transaction, full fund trace, frozen destination, packaged case.