Health Badge System¶
Overview¶
The health badge system assigns every representative a GREEN, AMBER, or RED compliance health flag. This flag is computed server-side by RepProfileService.computeRepHealth() (services/nestjs/src/domain/rep-profile/rep-profile.service.ts) and is visible in the Admin Rep Register, COMP dashboard, Rep Portal dashboard, and the chatbot's get_rep_register tool output.
The flag is a deterministic rule-engine rollup against MAS Notice FAA-N26 (the competency notice for Financial Adviser representatives, in force since 1 April 2024). The FAA-N26 CPD annual thresholds (FAA-N26 §5.4) are hardcoded as constants:
CPD_ANNUAL_MINIMUM_HOURS = 30 // FAA-N26 §5.4 Table 3 row 1(b): total for general investment product reps
CPD_CORE_MINIMUM_HOURS = 6 // FAA-N26 §5.4(a)(i): Core CPD (ethics/rules/regulations, IBF/SCI-accredited)
CPD_SUPPLEMENTARY_MINIMUM_HOURS = 24 // FAA-N26 §5.4(a)(ii) Table 3 row 1(b): Supplementary CPD
The Prisma model uses hoursEthics, hoursProduct, hoursOther as an activity-logging taxonomy. For health badge computation: Core CPD = hoursEthics, Supplementary CPD = hoursProduct + hoursOther.
The rollup is a closed specification (published regulation thresholds), making the deterministic computation CLAUDE.md S10-clean. The LLM-driven narrative path (_classify_rep_flag in chatbot_tools.py) remains available for advisory follow-up, but the admin-facing register uses the server-side computation exclusively.
Health Flag Definitions¶
GREEN¶
Definition: All required CMFAS modules are in good standing AND CPD hours meet all three FAA-N26 minima for the current calendar year.
Conditions:
- Zero CMFAS modules with status not_taken, failed, or expired
- cpdHoursYtd >= 30 (total)
- cpdHoursEthicsYtd >= 6 (Core CPD — ethics/rules/regulations, IBF/SCI-accredited)
- (cpdHoursProductYtd + cpdHoursOtherYtd) >= 24 (Supplementary CPD — all other CPD activities)
Transition out: GREEN -> AMBER when CPD hours fall below any minimum threshold. GREEN -> RED when any CMFAS module status changes to not_taken, failed, or expired.
AMBER¶
Definition: All required CMFAS modules are in good standing, BUT current-year CPD hours fall below at least one FAA-N26 minimum threshold.
Conditions:
- Zero CMFAS modules with status not_taken, failed, or expired
- At least one CPD shortfall: total < 30 OR Core CPD < 6 OR Supplementary CPD < 24
Transition to GREEN: Log sufficient CPD hours to meet ALL three minima. The CPD total includes all CpdRecord rows for the rep in the current calendar year. Core CPD is aggregated from hoursEthics; Supplementary CPD is aggregated from hoursProduct + hoursOther.
Transition to RED: A CMFAS module status changes to not_taken or failed.
RED¶
Definition: At least one required CMFAS module is NOT in good standing. A module is "not in good standing" when its effective status is not_taken, failed, or expired.
Effective status computation: A CMFAS row with stored status = "passed" but expiresAt in the past is treated as "expired" -- a belt-and-suspenders check against stale analyzer data.
Transition to GREEN: Pass the missing/failed module exam AND meet all three CPD minima. Passing the exam updates the CmfasModuleStatus row to status = "passed" with a fresh expiresAt. If CPD is simultaneously below thresholds, the flag becomes AMBER instead of GREEN.
CMFAS passes do NOT expire by time alone for health flag purposes: The expiry check is against expiresAt in the CmfasModuleStatus table, not against a fixed time window since passing. The module status row controls this via its expiresAt date.
Server-Side Computation¶
RepProfileService.computeRepHealth(repProfileId: number) returns a RepHealthSummary with:
{
repProfileId: number;
userId: string;
repNumber?: string | null;
userName?: string | null;
principalOrganisationId?: number | null;
healthFlag: "red" | "amber" | "green";
healthReasons: string[]; // human-readable explanation strings
cmfasStatus: Array<{moduleCode, status, expiresAt}>;
cpdHoursYtd: number; // total CPD hours year-to-date
cpdHoursEthicsYtd: number; // ethics-only hours YTD
cpdHoursProductYtd: number; // product-only hours YTD
cycleYear: number; // current UTC year
hasMissingRequired: boolean; // true when any CMFAS module is not_taken/failed/expired
}
Decision logic (in order):
1. Fetch rep profile + user data.
2. Determine cycleYear = new Date().getUTCFullYear().
3. Fetch all CmfasModuleStatus rows for the rep (via CmfasStatusService.findByRep()).
4. Fetch aggregated CPD totals for the rep in cycleYear (via CpdRecordService.totalHoursForRep()).
5. Check each CMFAS module for not_taken, failed, or expired (expiresAt < now).
6. If any bad module exists -> RED.
7. Else if any CPD minimum is not met -> AMBER.
8. Else -> GREEN.
Bulk computation: repProfilesWithHealth(orgId: number | null) returns RepHealthSummary[] for all reps in an org. Uses N+1 per rep (one CMFAS query + one CPD rollup query each). Acceptable for typical org sizes (tens to low hundreds of reps).
Data Sources¶
| Table | Columns Used | Purpose |
|---|---|---|
CmfasModuleStatus |
repProfileId, moduleCode, status, expiresAt |
Determine if any required module is not in good standing. Status values: passed, failed, expired, not_taken. |
CpdRecord |
repProfileId, cycleYear, hoursEthics, hoursProduct, hoursOther |
Aggregate CPD hours for the current calendar year. Total = ethics + product + other. |
RepProfile |
id, userId, repNumber, principalOrganisationId |
Link rep to user and org. |
Chatbot Integration¶
The get_rep_register chatbot tool (chatbot_tools.py:673) calls repProfilesWithHealth(orgId) via GraphQL and surfaces each rep's server-computed healthFlag, healthReasons, CPD hours, and hasMissingRequired. The tool returns these verbatim -- no LLM re-classification. When org is omitted, the tool defaults to ctx.org_id (the authenticated user's organisation binding).
Tool return shape per rep:
{
"rep_profile_id": 1,
"user_id": "abc-123...",
"rep_number": "RN-001",
"user_name": "John Tan",
"flag": "green",
"reasons": ["CMFAS good; CPD meets FAA-N26 minima"],
"cpd_hours_ytd": 35.5,
"cpd_hours_ethics_ytd": 10.0,
"cpd_hours_product_ytd": 6.0,
"cycle_year": 2026,
"has_missing_required": false
}
Demo Seed Distribution¶
30 rep profiles across 5 FIs, with load-bearing ordering (must stay in this sequence for distribution tests):
| Bucket | Count | Usernames |
|---|---|---|
| GREEN | 8 | dbs_rep1, dbs_rep2, uob_rep1, uob_rep2, ge_rep1, ge_rep2, manulife_rep1, aia_rep1 |
| AMBER | 14 | dbs_rep3, dbs_rep4, uob_rep3, uob_rep4, ge_rep3, ge_rep4, manulife_rep2, manulife_rep3, manulife_rep5, manulife_rep6, aia_rep2, aia_rep3, aia_rep6, demo_rep2 |
| RED | 8 | dbs_rep5, dbs_rep6, uob_rep5, uob_rep6, ge_rep5, manulife_rep4, aia_rep4, aia_rep5 |
Distribution per FI: DBS (6), UOB (6), GE (5), Manulife (6), AIA (6), plus demo_rep2 (standalone REP_USER test account in Keycloak).
Visibility Points¶
| Surface | Endpoint / Page | What is Shown |
|---|---|---|
| Admin Rep Register | /admin/rep-register |
Full table with health badge (colored R/A/G), reasons, CPD hours per rep |
| COMP Dashboard | /admin/dashboard |
Health summary card with GREEN/AMBER/RED counts |
| Rep Portal Dashboard | /rep/dashboard |
Rep's own health badge + reasons |
| Chatbot | get_rep_register tool |
Health flags + reasons for all reps in queried org |
Key Architectural Decisions¶
-
Server-side deterministic rollup, not LLM: The health flag uses hardcoded FAA-N26 thresholds. This is a closed specification, not a semantic judgement. The LLM
_classify_rep_flagpath exists only for narrative advisory follow-up, not for the admin-facing register. -
"Required modules" = all modules the rep carries: There is no separate "required module list" table. The Cert Analyzer upserts one
CmfasModuleStatusrow per required module when the rep is onboarded. A missing row is only possible before onboarding. -
Expired check is belt-and-suspenders: Even if
status = "passed", anexpiresAtin the past overrides to"expired". This protects against stale analyzer data. -
CMFAS status takes priority over CPD: RED (CMFAS gap) overrides AMBER (CPD shortfall). The order of checks is: CMFAS gaps first (RED), then CPD shortfalls (AMBER), then all-good (GREEN).