學習目標 · Learning Objectives
- State the Hong Kong legal and regulatory basis for KYC in insurance: the Anti-Money Laundering and Counter-Terrorist Financing Ordinance (打擊洗錢及恐怖分子資金籌集條例, Cap. 615) and its subsidiary regulation, the FATF standards it implements, the Insurance Authority's supervisory expectations for authorised institutions, and what an individual agent actually has to do differently because of it.
- Distinguish identity verification (who is this person: HKID, passport, travel permit, plus a genuine physical presence check) from address verification (where do they live: a document, its recency, and the rule about accepting third-party addresses), know why an ID alone is never sufficient for an insurance application, and explain how document OCR (optical character recognition), liveness detection (活體檢測), and matching work in an eKYC flow — the failure modes of each, and which of them an agent may never override at the counter.
- Run sanctions and PEP (politically exposed person) screening to a defensible outcome: interpret a match, apply the four-eyes (雙人覆核) escalation rule, keep records to the required retention period, and write the record in a shape that survives an IA examination.
KYC & eKYC (KYC 與 eKYC)
Lesson 09 · Insurance POS 101 · Hong Kong market · agent-facing
Learning Objectives
- State the Hong Kong legal and regulatory basis for KYC in insurance: the Anti-Money Laundering and Counter-Terrorist Financing Ordinance (打擊洗錢及恐怖分子資金籌集條例, Cap. 615) and its subsidiary regulation, the FATF standards it implements, the Insurance Authority's supervisory expectations for authorised institutions, and what an individual agent actually has to do differently because of it.
- Distinguish identity verification (who is this person: HKID, passport, travel permit, plus a genuine physical presence check) from address verification (where do they live: a document, its recency, and the rule about accepting third-party addresses), know why an ID alone is never sufficient for an insurance application, and explain how document OCR (optical character recognition), liveness detection (活體檢測), and matching work in an eKYC flow — the failure modes of each, and which of them an agent may never override at the counter.
- Run sanctions and PEP (politically exposed person) screening to a defensible outcome: interpret a match, apply the four-eyes (雙人覆核) escalation rule, keep records to the required retention period, and write the record in a shape that survives an IA examination.
The Legal Basis in Hong Kong
In practice: when a client asks "why do you want a bank statement?", the answer is not politeness or bank secrecy — it is a statutory obligation under Cap. 615, and the agent who cannot name the obligation is in a weak position when the client pushes back.
The instruments
| Instrument | What it is | What it obliges an insurer/agent to do |
|---|---|---|
| AMLO (Cap. 615) — Anti-Money Laundering and Counter-Terrorist Financing Ordinance | The primary AML statute in Hong Kong | Customer due diligence (CDD) on establishing a relationship; ongoing due diligence; enhanced due diligence where risk warrants; suspicious transaction reporting to the JFIU |
| AMLO (General) Regulation (Cap. 615, subleg) | Implements FATF 40 recommendations into HK law | Customer identification, verification of identity, beneficial owner identification, risk classification, record-keeping periods, agent/introducer requirements |
| Guidance on AML/CFT for Authorized Institutions (HKMA) / Guidance on Anti-Money Laundering and Counter-Terrorist Financing for Insurance Companies (IA) | Sector supervisory guidance | How an insurer must operationalise CDD, how long records are kept, when EDD is triggered, what the MLRO (money laundering reporting officer) must approve |
| FATF Recommendations | The international standard, risk-based | Risk-based approach: CDD depth scales with customer risk; higher risk → stronger verification |
| Personal Data (Privacy) Ordinance (Cap. 486) | HK privacy law | Collection must be necessary, proportionate, transparent; client has data-access and correction rights; cross-border transfer constraints |
| Unconscionable Contracts Ordinance / general agency duty | Contract fairness | A term or exclusion that is unconscionable given the parties is not enforced; relevant to how KYC information may be used |
| Insurance Authority Best Practice Guide series | Operational compliance guidance | Agent conduct, disclosure, illustrations, record-keeping expectations; the practical layer an agent actually feels |
The chain an agent should be able to state out loud, without notes: we are required by the AMLO, which implements FATF, supervised by the Insurance Authority, to identify you, verify who you are and where you live, understand the source of the money paying for the policy, screen you against sanctions and PEP lists, keep records for five years after the relationship ends or the policy ends — whichever is later — and report anything suspicious to the JFIU without telling you.
The two facts that surprise agents
1. Money laundering in insurance is mostly about getting money out, not putting it in. The classic predicate in HK insurance is an overpayment: a client pays HK$2m of premium into a policy they intend to surrender within months, and the money goes out to a beneficiary or a third party while a small proportion is retained as commission. The AML controls that catch this are (a) refusing third-party payment, (b) requiring the payment account to be in the proposer's own name, (c) watching for rapid surrender after inception, and (d) trigger reporting on refund requests. The POS therefore treats source of funds and payment account name as first-class fields with hard validation, not nice-to-have boxes.
2. Insurance agents are a recognised weak point, and the regime is built to close that gap. Under the AMLO's approach, where a business relationship is established through an intermediary, the insurer can rely on the intermediary having applied CDD, but it must instruct and monitor the intermediary and must obtain the CDD information. Practically this becomes: the agent must gather the information, the agent must not gather it sloppily, and the insurer's compliance function must be able to reconstruct the agent's steps years later. That is why the POS stores a StateTransition-style trail (Lesson 08) with actor and timestamp for every KYC step, and stores the raw evidence with a hash.
What an agent personally must do differently
| Obligation | The agent's concrete behaviour |
|---|---|
| Don't tip off the client about suspicious transaction reporting | Never say "this looks like AML" or "I have to report you"; complete the transaction or stop it neutrally |
| Don't accept a third-party payment | If the payer is not the proposer, stop and escalate; do not "just accept it this once" |
| Keep the client's documents safe | Scanned ID stored in HK-resident storage, masked in the UI, never in a personal WhatsApp, never on a personal laptop |
| Record the checks, not just the result | A tick-box saying "KYC done" is not a record; the record is the document, its hash, the source, the matcher version, and who reviewed it |
| Know when to stop | An agent who is uneasy can and must refuse to proceed and refer to the MLRO; that is a protected act, not a career limitation |
| Don't open the account for a friend, relative, or "network" | Beneficial-ownership awkwardness is exactly what EDD is for; the agent may not be the customer's nominee or accountant on the same case |
ID Verification vs Address Proof
In practice: the client slides their HKID across the table and says "is that enough?" — and the honest answer is "that tells me who you are; I still need to see where you live, because the application form asks for your residential address and I have to be able to prove it."
The two questions, and why they are separate
| Identity verification (身分核實) | Address verification (地址證明) | |
|---|---|---|
| Question answered | Who is this person? | Where do they live? |
| Acceptable evidence (typical HK agency) | HKID card (original, both sides); HKID replacement; passport; Mainland Travel Permit; HKID Application Receipt for those without a card | Bank statement; utility bill (水電費單); tenancy agreement (租約); government correspondence addressed to them; employer letter |
| Recency rule | Must be valid on the day of application (not expired) | Commonly must be within 3 months; some institutions accept 6 months for a lower-risk case |
| Technical check | HKID number format + check digit; name in Latin/Chinese; DOB consistency; document authenticity; liveness if eKYC | Name and address on the document match the application; document is genuine; address is a residential address, not a PO box |
| Third-party address | Not applicable | Allowed only with a documented reason (e.g. living with parents, in a dormitory, in a company-provided flat). A written statement from the client plus the third party's consent, and the third party's address as the mailing address, and often an extra confirmation call to the third party |
| Common failure | Expired HKID; a photocopy; a photo of a photo | A bank statement of an account the client does not hold; a mobile-phone bill (many carriers will not accept it alone); an old statement; an address on the ID card that is years out of date |
A Hong Kong ID card shows an address, but it is often the address as at issue of the card, not the current one, and it is not a "document" issued for the purpose of proving residence. Treating the ID address as proof of residence is the most common KYC shortcut and it is the one the regulator looks for.
The decision logic, as code
export type IdDocumentKind =
| "HKID_CARD" | "HKID_REPLACEMENT" | "HKID_APPLICATION_RECEIPT"
| "PASSPORT" | "MAINLAND_TRAVEL_PERMIT" | "BIRTH_CERTIFICATE_HK"
| "OTHER_ACCEPTED";
export interface IdentityEvidence {
kind: IdDocumentKind;
frontImageSha256: string;
backImageSha256?: string;
issuedCountry: string; // ISO-3166 alpha-3; "HKG" for HKID
documentNumberMasked: string; // e.g. "A123456(7)" → store masked + encrypted original
nameZh: string;
nameEn: string;
dateOfBirth: string; // ISO
addressOnDocument: string;
validFrom?: string;
validUntil?: string;
hkidChecksumValid?: boolean; // only for HKID kinds
authenticityScore?: number; // 0..1 from the document vendor
tamperScore?: number; // 0..1, higher = more likely a photo of a photo
capturedVia: "IN_PERSON_SCAN" | "UPLOAD" | "MOBILE_CAMERA" | "NFC_CHIP_READ";
}
export interface AddressEvidence {
kind:
| "BANK_STATEMENT" | "UTILITY_BILL" | "TENANCY_AGREEMENT"
| "GOVERNMENT_CORRESPONDENCE" | "EMPLOYER_LETTER" | "THIRD_PARTY_STATEMENT";
documentSha256: string;
statementDate: string;
nameOnDocument: string;
addressOnDocument: string;
monthsOld: number;
issuingInstitution?: string;
isResidential: boolean;
isDomicileAddress?: boolean;
thirdParty?: {
reason: "LIVING_WITH_PARENT" | "DORMITORY" | "EMPLOYER_HOUSING" | "RELOCATION_PENDING" | "OTHER";
thirdPartyName: string;
thirdPartyConsentObtained: boolean;
thirdPartyConfirmCallMade: boolean;
mailingAddressOverride: string; // where mail actually goes
};
}
export type KycOutcome =
| { outcome: "PASS"; confidence: number }
| { outcome: "FAIL"; reasonCode: KycFailCode; reason: string; clientMessageZh: string }
| { outcome: "ESCALATE"; reasonCode: string; reason: string; to: "MLRO" | "COMPLIANCE_OFFICER" };
export type KycFailCode =
| "ID_EXPIRED" | "ID_CHECKSUM_FAIL" | "ID_AUTHENTICITY_LOW" | "ID_TAMPER_SUSPECTED"
| "NAME_MISMATCH_ID_VS_APPLICATION" | "DOB_MISMATCH"
| "ADDRESS_PROOF_MISSING" | "ADDRESS_PROOF_STALE" | "ADDRESS_NOT_RESIDENTIAL"
| "ADDRESS_THIRD_PARTY_UNSUBSTANTIATED" | "LIVENESS_FAILED" | "FACE_MATCH_LOW"
| "SCREENING_PEP_POTENTIAL" | "SCREENING_SANCTIONS_POTENTIAL" | "SOURCE_OF_FUNDS_UNCLEAR";
/**
* Two-stage KYC. Identity and address are evaluated INDEPENDENTLY —
* a perfect ID does not compensate for a missing address proof, and a good
* address proof does not compensate for a failed liveness check.
*/
export function verifyIdentityAndAddress(
id: IdentityEvidence,
addr: AddressEvidence | null,
live: LivenessResult,
faceMatch: FaceMatchResult,
declared: { nameEn: string; nameZh: string; dateOfBirth: string; address: string },
ctx: { now: Date; maxAddressMonths: number; thirdPartyAllowed: boolean },
): KycOutcome {
/* ---------------- IDENTITY ---------------- */
const now = ctx.now;
if (id.kind === "HKID_CARD" || id.kind === "HKID_REPLACEMENT") {
if (id.validUntil && new Date(id.validUntil) < now) {
return { outcome: "FAIL", reasonCode: "ID_EXPIRED",
reason: `Identity document expired on ${id.validUntil}`,
clientMessageZh: "你出示的身份證明文件已過期,請提供有效的證件。" };
}
if (id.hkidChecksumValid === false) {
// Never auto-correct. Ask the client to re-read from the card.
return { outcome: "FAIL", reasonCode: "ID_CHECKSUM_FAIL",
reason: "HKID check digit does not validate; the number as typed is not a valid HKID number",
clientMessageZh: "身份證號碼輸入不正確,請再核對身份證上的號碼。" };
}
}
if ((id.authenticityScore ?? 1) < 0.6) {
return { outcome: "FAIL", reasonCode: "ID_AUTHENTICITY_LOW",
reason: `Document authenticity score ${id.authenticityScore} below threshold`,
clientMessageZh: "證件鑒真度不足,請於分行或親身到門市重新辦理。" };
}
if ((id.tamperScore ?? 0) > 0.7) {
// A photo of a photo, or an edited image. This is not a "try again" — this escalates.
return { outcome: "ESCALATE", reasonCode: "ID_TAMPER_SUSPECTED",
reason: `Tamper score ${id.tamperScore} suggests image manipulation or a screenshot`,
to: "MLRO" };
}
if (normaliseName(id.nameEn) !== normaliseName(declared.nameEn) ||
normaliseName(id.nameZh) !== normaliseName(declared.nameZh)) {
return { outcome: "FAIL", reasonCode: "NAME_MISMATCH_ID_VS_APPLICATION",
reason: `Name on ID (${id.nameEn} / ${id.nameZh}) does not match the application (${declared.nameEn} / ${declared.nameZh})`,
clientMessageZh: "身份證明文件上的姓名同申請表不符,請確認並更正。" };
}
if (id.dateOfBirth !== declared.dateOfBirth) {
return { outcome: "FAIL", reasonCode: "DOB_MISMATCH",
reason: `Date of birth on ID (${id.dateOfBirth}) does not match the application (${declared.dateOfBirth})`,
clientMessageZh: "出生日期同申請表不符,請確認並更正。" };
}
if (!live.passed) {
// Liveness is never overridden by an agent. Not once. Not for a nice client.
return { outcome: "FAIL", reasonCode: "LIVENESS_FAILED",
reason: `Liveness check failed: ${live.reason}. An agent may not override liveness.`,
clientMessageZh: "活體檢測未能通過,請親臨門市或分行辦理身份核實。" };
}
if (faceMatch.score < 0.85) {
return { outcome: "ESCALATE", reasonCode: "FACE_MATCH_LOW",
reason: `Face match score ${faceMatch.score} below auto-pass threshold; manual review required`,
to: "COMPLIANCE_OFFICER" };
}
/* ---------------- ADDRESS ---------------- */
if (!addr) {
return { outcome: "FAIL", reasonCode: "ADDRESS_PROOF_MISSING",
reason: "No address evidence supplied; an identity document alone is not sufficient",
clientMessageZh: "請提供住址證明,例如三個月內的銀行月結單或水電費單。" };
}
if (addr.monthsOld > ctx.maxAddressMonths) {
return { outcome: "FAIL", reasonCode: "ADDRESS_PROOF_STALE",
reason: `Address proof is ${addr.monthsOld} months old; limit is ${ctx.maxAddressMonths}`,
clientMessageZh: `住址證明已超過 ${ctx.maxAddressMonths} 個月,請提供較近期的文件。` };
}
if (!addr.isResidential) {
return { outcome: "FAIL", reasonCode: "ADDRESS_NOT_RESIDENTIAL",
reason: "Address evidence shows a PO box or commercial-only address; a residential address is required",
clientMessageZh: "請提供居住地址的證明,郵政信箱或商業地址不適用。" };
}
if (normaliseAddress(addr.addressOnDocument) !== normaliseAddress(declared.address)) {
return { outcome: "FAIL", reasonCode: "NAME_MISMATCH_ADDRESS" as KycFailCode,
reason: "Address on the evidence does not match the address declared on the application",
clientMessageZh: "住址證明上的地址同申請表填寫的不符,請更正。" };
}
if (addr.thirdParty) {
const tp = addr.thirdParty;
if (!ctx.thirdPartyAllowed) {
return { outcome: "FAIL", reasonCode: "ADDRESS_THIRD_PARTY_UNSUBSTANTIATED",
reason: "Third-party address not accepted for this product/channel",
clientMessageZh: "此產品不接受第三方住址,請提供以你本人名義的住址證明。" };
}
if (!tp.thirdPartyConsentObtained || !tp.thirdPartyConfirmCallMade) {
return { outcome: "FAIL", reasonCode: "ADDRESS_THIRD_PARTY_UNSUBSTANTIATED",
reason: "Third-party address lacks consent or third-party confirmation",
clientMessageZh: "使用第三方住址需要對方同意並經核實,請補齊文件。" };
}
}
return { outcome: "PASS", confidence: Math.min(1, (id.authenticityScore ?? 0.7) * 0.5 + faceMatch.score * 0.5) };
}
Document OCR
In practice: the agent's tablet captures the HKID and the OCR fills the client's name, date of birth and ID number into the application in seconds — and the agent's job is to check the four critical fields against the card and never to accept an OCR field the client did not confirm.
What OCR does in this flow
| Field | OCR reliability | What the agent must do |
|---|---|---|
| HKID number | Very high, but a check-digit error turns a typo into an invalid or wrong identity | Read back the number from the card; never let OCR set it silently |
| Chinese name | High on modern fonts, drops on stylized cards | Read back |
| English name | High | Read back for exactness of given names vs surname order |
| Date of birth | High | Read back |
| Address on the ID | Medium — multi-line, sometimes abbreviated | Read back; but this is not address proof (see above) |
| Bank statement: name + address | High | Read back the address against the application |
| Bank statement: balances/transactions | Medium | Never auto-accept a figure into a financial-justification field; the client confirms |
The engineering rules that make OCR safe to use in a regulated flow:
export interface OcrField {
path: string; // "/parties/1/hkidNumber"
rawText: string;
confidence: number; // 0..1
bbox: [number, number, number, number];
sourceDocSha256: string; // ties the field to the exact image
engineVersion: string;
autoAccepted: boolean; // true only if autoAcceptRules passed
confirmedBy?: "CLIENT_READBACK" | "AGENT_VISUAL_CHECK" | "CHIP_READ" | null;
}
const AUTO_ACCEPT_RULES: Record<string, (f: OcrField) => boolean> = {
// ID number: high OCR confidence AND a valid HKID check digit AND read back.
"/parties/*/hkidNumber": (f) => f.confidence >= 0.95 && hkidCheckDigit(f.rawText) !== null,
// DOB: high confidence; still read back because a wrong DOB changes rating.
"/parties/*/dateOfBirth": (f) => f.confidence >= 0.9,
// Address on ID: NEVER auto-accept into the application's residential address —
// it is not address proof and it is frequently stale.
"/parties/*/addressOnDocument": () => false,
// Bank statement balance: never auto-accept into a financial-justification field.
"/financials/assets": () => false,
};
export function shouldAutoAccept(path: string, f: OcrField): boolean {
const rule = AUTO_ACCEPT_RULES[path];
if (!rule) return false;
if (!rule(f)) return false;
// Nothing identity-critical is auto-accepted into a signature-bearing field
// without a human confirmation event.
return f.confidence >= 0.9;
}
/** Every OCR value that lands in the application is bound to the image it came from. */
export function buildOcrProvenance(fields: OcrField[]): OcrProvenance {
return {
engine: "vendor-ocr/4.2.1",
capturedAt: new Date().toISOString(),
fields: fields.map((f) => ({
...f,
// If the source document hash changes, this field is stale and must be re-derived.
stale: f.sourceDocSha256 !== currentHashFor(f.path),
})),
};
}
The stale flag matters more than it looks. The client uploads a bank statement, the OCR pulls the address, then the client re-uploads a corrected or more recent statement — and if the application still carries the address from the first upload, the POS is holding evidence that contradicts itself. Provenance with staleness detection is how that is caught.
Liveness Detection
In practice: when the client is doing the eKYC from home on their phone, the camera asks them to turn their head, blink, and hold the HKID up next to their face — and if the check fails, the client is told to go to a branch or an agent. The agent's job is not to "try again until it passes"; it is to route the failure correctly.
The two checks, and why both exist
| Check | What it defends against | Mechanism |
|---|---|---|
| Liveness (活體檢測) | Presenting a photo of someone else, a video, a mask, a deepfake | Random challenge (turn left, blink, smile), passive micro-motions, texture/reflectance analysis, depth cue where available |
| Face match (人臉比對) | The document belongs to someone other than the person in front of the camera | Embedding comparison of the selfie frame vs the document portrait, with a threshold |
A 3D mask attack and a printed-photo attack defeat different things. Liveness alone can be beaten by a good mask in weak conditions; face match alone can be beaten by a photo if the "selfie" is not live. The POS therefore requires both, and treats liveness as non-overridable while face-match has a manual-review band.
export interface LivenessResult {
passed: boolean;
method: "PASSIVE" | "ACTIVE_CHALLENGE" | "DEPTH_SENSOR" | "NFC_CHIP_READ";
challengesCompleted: number;
spoofScore: number; // 0..1, higher = more likely spoof
reasons: string[]; // e.g. "no_blink_in_3s", "flat_texture_print", "replay_same_frame"
overridePermitted: false; // type-level: there is no code path that sets this true
}
export interface FaceMatchResult {
score: number; // cosine similarity of embeddings
autoPassThreshold: number; // e.g. 0.85
manualBand: [number, number]; // e.g. [0.70, 0.85] ⇒ manual review
autoFailBelow: number; // e.g. 0.70
spoofCorrelation: number; // if liveness looks odd, treat a high face score with suspicion
}
// A manual override of liveness is not a code path that exists. By typing the
// field as `overridePermitted: false` and never reading it as an if-condition,
// the capability is absent rather than merely discouraged.
export function decideFaceMatch(fm: FaceMatchResult, live: LivenessResult): KycOutcome {
if (!live.passed) {
return { outcome: "FAIL", reasonCode: "LIVENESS_FAILED",
reason: "Liveness failed; no manual override exists in this system",
clientMessageZh: "活體檢測未通過,請親身到門市或分行辦理。" };
}
if (fm.score >= fm.autoPassThreshold) {
return { outcome: "PASS", confidence: fm.score };
}
if (fm.score < fm.autoFailBelow) {
return { outcome: "ESCALATE", reasonCode: "FACE_MATCH_LOW",
reason: `Face match ${fm.score} below auto-fail; treat as possible document/face mismatch`,
to: "COMPLIANCE_OFFICER" };
}
return { outcome: "ESCALATE", reasonCode: "FACE_MATCH_REVIEW_BAND",
reason: `Face match ${fm.score} inside the manual review band [${fm.autoBand.join(",")}]`,
to: "COMPLIANCE_OFFICER" };
}
Real-world failure modes, and the right response
| Failure | What happened | Correct response | Wrong response |
|---|---|---|---|
| Elderly client's phone has no front camera / poor lighting | Liveness fails repeatedly | Offer in-person or branch verification; do not coach the client to "hold the phone near a lamp" repeatedly | Tell the client to keep trying until it passes (pressure + rubber-stamping) |
| Client wears a mask | Passive/active challenge fails | Re-ask for the active challenge without the mask, or route in person | Lower the threshold |
| Client is a twin of the document holder | Face match in the manual band | Manual review; capture additional ID; escalate | Auto-pass "they look the same" |
| Video replay / deepfake | Spoof score high | Escalate to MLRO; this is a fraud indicator, not a UX problem | Retry |
| Screen re-photography of the ID | Tamper/print score high | Escalate; capture the ID again in person | Auto-accept the OCR |
The mindset to hold: liveness failures are routing decisions, not obstacles. The system is not being difficult; it is refusing to let a document be accepted without a person.
Sanctions and PEP Screening
In practice: every client — and often every beneficiary and every payer — is screened against the sanctions and PEP lists at onboarding, and again on a rolling basis. When something matches, the agent sees a red card and a number of buttons, exactly one of which is acceptable to press.
The three lists and what each means
| List | What it covers | Effect of a true match |
|---|---|---|
| UN sanctions (via HK, implementing UNSCR) | Named individuals/entities designated by the UN Security Council | Must not provide any service; report to the JFIU; cannot be tipped off |
| Local sanctions under the UNATMO (United Nations Ant-Terrorist Motives Ordinance, Cap. 537) | UN-designated terrorist persons/entities | Must not provide any service; report |
| PEP — politically exposed person | Current or former senior officials: heads of state, ministers, senior civil servants, military officers, judges, senior political party officials, their immediate family and close associates | Not an automatic block. Requires enhanced due diligence (EDD): source of wealth, source of funds, senior approval, ongoing monitoring. A PEP is not a criminal; a PEP who is also a sanctions target is a block |
| Adverse media | News linking the person to corruption, fraud, bribery, laundering | Not a list in the legal sense but a screening source; a hit triggers EDD and human judgement, not an automatic block |
Interpreting a screening hit: the decision table
This is the table that decides an agent's case. Note that none of the outcomes are "carry on anyway".
| # | List / match | Match strength | Client context | Outcome | Who decides | Required records | Timebox |
|---|---|---|---|---|---|---|---|
| 1 | Sanctions (UN / UNATMO) | True match on name + DOB (or DOB alone) | Any | BLOCK — do not proceed, do not tip off | Automatic + MLRO notified | Reason code, list version, match evidence, MLRO ack | Same day |
| 2 | Sanctions | Fuzzy name match only, no DOB/other match | Any | ESCALATE → MLRO; manual name adjudication | MLRO | Fuzzy score, alternatives considered, decision + reasoning | Same day |
| 3 | Sanctions | True match, but likely false positive (e.g. very common name, wrong nationality) | Any | ESCALATE → MLRO; MLRO documents why it is false | MLRO | Full adjudication note; never agent-decided | Same day |
| 4 | PEP — domestic | Exact match, senior official | Any | EDD: source of wealth, source of funds, senior approval, ongoing monitoring. May still be insured | Compliance officer | EDD file, approval name/time | Before submission |
| 5 | PEP — foreign | Exact match | Any | EDD + enhanced monitoring + senior approval; heightened scrutiny of third-party payments | Compliance officer | EDD file, senior approval | Before submission |
| 6 | PEP — close associate / family of a PEP | Possible match | Any | EDD, documented reason | Compliance officer | EDD file | Before submission |
| 7 | Adverse media | Report mentioning the client | Any | EDD + manual review of the report; consider reporting if it suggests laundering | Compliance officer / MLRO | Media report ref, review note, decision | 3 working days |
| 8 | Sanctions — client's beneficiary or payer | Any | Any | Same as 1–3, applied to that person too | MLRO | As above | Same day |
| 9 | Client's common name on a list, with a different nationality and DOB | Fuzzy, low likelihood | Any | Documented as "considered and excluded" — do not drop the hit | Agent records, MLRO samples | Exclusion reasoning | Same day |
The last row is the one agents forget: a screening hit that is not reported is as much of a problem as one that is mishandled, because "we looked and it was fine" is only credible if you recorded the lookup.
Screening result schema
export interface ScreeningResult {
screeningId: string;
caseId: string; // ties to an application/policy/customer
subject: ScreeningSubject;
screenedAt: string; // ISO-8601
screenerVersion: string; // vendor + list build, e.g. "vendor-x/5.2.0 lists/2026-03-11"
listsChecked: Array<{
listId: "UN_SANCTIONS" | "UNATMO" | "HKSAR_LOCAL" | "PEP_GLOBAL" | "ADVERSE_MEDIA";
buildId: string;
entriesScreened: number;
}>;
outcome: "CLEAR" | "HIT" | "ESCALATE";
hits: ScreeningHit[];
matchFactors: MatchFactors; // which identifiers were compared
disposition: ScreeningDisposition;
reviewer?: FourEyesReview; // required whenever disposition != AUTO_CLEAR
auditHash: string; // tamper-evidence over the whole record
}
export interface ScreeningSubject {
type: "PROPOSER" | "LIFE_ASSURED" | "BENEFICIARY" | "TRUSTEE" | "PAYER" | "AUTHORISED_SIGNATORY";
partyId: string;
nameEn: string; nameZh: string;
dateOfBirth?: string; // a strong disambiguator when present
nationality?: string;
hkidNumberMasked?: string;
countryOfResidence?: string;
}
export interface MatchFactors {
nameExact: boolean;
nameFuzzyScore: number; // 0..1
nameTokenOverlap: string[]; // e.g. ["CHAN","SIU","MING"]
dobMatches: boolean | null; // null = no DOB to compare
nationalityMatches: boolean | null;
countryMatches: boolean | null;
idNumberMatches: boolean | null;
addressMatches: boolean | null;
listEntry: { // what was actually on the list
listId: string;
primaryName: string;
aliases: string[];
dob?: string;
nationality?: string;
programme?: string; // e.g. "SDGT", "CD-UN", "EBL-UK" (if adopted)
listedOn?: string;
};
}
export interface ScreeningHit {
hitId: string;
listId: string;
matchStrength: "EXACT" | "STRONG" | "FUZZY" | "WEAK";
/** The single most important field: what makes this a true match. */
corroboratingFactors: string[]; // e.g. ["nameExact","dobMatches","nationalityMatches"]
/** A true sanctions match requires sanctions-relevant corroboration OR MLRO adjudication. */
trueMatchProbability: number;
}
export type ScreeningDisposition =
| "AUTO_CLEAR" // no hit; auto-cleared by screener
| "FALSE_POSITIVE_DOCUMENTED" // agent documents why excluded; MLRO samples
| "EDD_REQUIRED" // PEP / adverse media ⇒ enhanced due diligence
| "BLOCK_AND_REPORT" // true sanctions match ⇒ block + JFIU report
| "REFERRED_TO_MLRO"; // uncertain ⇒ MLRO adjudicates
export interface FourEyesReview {
firstReviewer: { agentId: string; name: string; at: string; conclusion: string };
secondReviewer: { complianceOfficerId: string; name: string; at: string; conclusion: string };
samePersonCheck: "PASS"; // system asserts reviewer IDs differ
decision: "PROCEED_STANDARD" | "PROCEED_WITH_EDD" | "BLOCK_AND_REPORT" | "REFER_EXTERNAL_COUNSEL";
rationale: string; // free text, min 40 chars, cannot be boilerplate
}
export const SCREENING_POLICY: Record<string, {
requiresFourEyes: boolean;
allowedDispositions: ScreeningDisposition[];
slaHours: number;
notify?: "MLRO" | "COMPLIANCE_OFFICER";
}> = {
UN_SANCTIONS: { requiresFourEyes: false, allowedDispositions: ["BLOCK_AND_REPORT"], slaHours: 0, notify: "MLRO" },
UNATMO: { requiresFourEyes: false, allowedDispositions: ["BLOCK_AND_REPORT"], slaHours: 0, notify: "MLRO" },
HKSAR_LOCAL: { requiresFourEyes: false, allowedDispositions: ["BLOCK_AND_REPORT"], slaHours: 0, notify: "MLRO" },
PEP_GLOBAL: { requiresFourEyes: true, allowedDispositions: ["EDD_REQUIRED"], slaHours: 24, notify: "COMPLIANCE_OFFICER" },
ADVERSE_MEDIA: { requiresFourEyes: true, allowedDispositions: ["EDD_REQUIRED"], slaHours: 72, notify: "COMPLIANCE_OFFICER" },
FUZZY_ANY: { requiresFourEyes: true, allowedDispositions: ["FALSE_POSITIVE_DOCUMENTED","REFERRED_TO_MLRO"], slaHours: 24, notify: "MLRO" },
};
falsePositiveDocumented is available to the agent, but BLOCK_AND_REPORT is not: SCREENING_POLICY.UN_SANCTIONS.allowedDispositions contains only BLOCK_AND_REPORT, so no code path lets an agent clear a sanctions hit. That asymmetry is the design.
Four-Eyes Escalation
In practice: a case that hits any of these — face match in the manual band, PEP match, adverse-media hit, fuzzy sanctions match, third-party address, premium above a configured threshold, source of funds over a configured amount — does not proceed on the agent's authority. It goes into a queue, a compliance officer reviews it, and the case waits. The agent's job is to write the referral well, not to push it through.
What triggers escalation
| Trigger | Threshold (typical) | Why |
|---|---|---|
| Face match in manual band | score 0.70–0.85 | Avoid both auto-fail on a genuine match and auto-pass on a mismatch |
| PEP match | any true match | FATF Recommendation 12 |
| Adverse-media hit | any | Needs human judgement on materiality |
| Fuzzy sanctions match | any | Never clear without adjudication |
| Third-party residential address | any | Known vector for nominee addresses |
| Premium / single premium | above e.g. HK$1m | Enhanced scrutiny of source of funds |
| Source of funds not ordinary (asset sale, inheritance, third-party gift, crypto proceeds) | any | Classic laundering indicator |
| Client declines liveness after several attempts | any | A refusal is itself a signal |
| The agent feels uneasy and cannot articulate why | any | The single most valuable escalation reason, and always valid |
The escalation mechanics
export interface Escalation {
escalationId: string;
caseId: string;
applicationId: string;
reason: EscalationReason;
raisedBy: string; // agent id
raisedAt: string;
summary: string; // min 40 chars, must state the facts not the conclusion
evidenceRefs: string[]; // screeningId, doc hashes, liveness result
requestedOf: "COMPLIANCE_OFFICER" | "MLRO";
status: "OPEN" | "IN_REVIEW" | "RESOLVED_EDD" | "RESOLVED_PROCEED" | "RESOLVED_BLOCK" | "REJECTED";
slaExpiresAt: string;
resolution?: {
decidedBy: string;
decidedAt: string;
decision: "PROCEED_STANDARD" | "PROCEED_WITH_EDD" | "BLOCK_AND_REPORT" | "ASK_MORE_EVIDENCE";
conditions?: string[]; // e.g. "require 6 months' source-of-funds documentation"
rationale: string; // min 40 chars
};
}
export enum EscalationReason {
FACE_MATCH_REVIEW_BAND,
LIVENESS_REPEATEDLY_FAILED,
PEP_MATCH,
ADVERSE_MEDIA_HIT,
FUZZY_SANCTIONS_MATCH,
THIRD_PARTY_ADDRESS,
SOURCE_OF_FUNDS_UNUSUAL,
PREMIUM_ABOVE_THRESHOLD,
THIRD_PARTY_PAYMENT,
DOCUMENT_TAMPER_SUSPECTED,
AGENT_CONCERN_UNARTICULATED,
DECLINE_IN_LANGUAGE_PERSONA, // same person, several IDs, language shifts
}
export function createEscalation(input: {
applicationId: string; reason: EscalationReason; summary: string;
evidenceRefs: string[]; requestedOf: Escalation["requestedOf"];
}): Escalation {
const now = new Date();
const slaHours = input.reason === EscalationReason.FUZZY_SANCTIONS_MATCH ? 24 : 48;
return {
escalationId: `ESC-${input.applicationId}-${now.getTime()}`,
caseId: input.applicationId,
applicationId: input.applicationId,
reason: input.reason,
raisedBy: currentAgentId(),
raisedAt: now.toISOString(),
summary: requireMinChars(input.summary, 40, "state the facts, not your conclusion"),
evidenceRefs: input.evidenceRefs,
requestedOf: input.requestedOf,
status: "OPEN",
slaExpiresAt: new Date(now.getTime() + slaHours * 3600_000).toISOString(),
};
}
/**
* Four-eyes: the second reviewer must not be the first, and a case cannot be
* released by the person who raised it. This is enforced as data, not as advice.
*/
export function resolveEscalation(esc: Escalation, decision: NonNullable<Escalation["resolution"]>): Escalation {
if (decision.decidedBy === esc.raisedBy) {
throw new FourEyesViolationError("the agent who raised the escalation cannot decide it");
}
const reviewer = lookupReviewer(decision.decidedBy);
if (reviewer.role !== (esc.requestedOf === "MLRO" ? "MLRO" : "COMPLIANCE_OFFICER")) {
throw new WrongRoleError(`escalation to ${esc.requestedOf} must be decided by that role`);
}
if (requireMinChars(decision.rationale, 40, "reasoning") !== true) {
throw new RationaleTooShortError();
}
// A sanctions-related escalation may only be released one way.
if (esc.reason === EscalationReason.FUZZY_SANCTIONS_MATCH &&
!["PROCEED_STANDARD", "BLOCK_AND_REPORT"].includes(decision.decision)) {
throw new IllegalDispositionError("sanctions escalations may only be PROCEED_STANDARD or BLOCK_AND_REPORT");
}
return {
...esc,
status: decision.decision === "BLOCK_AND_REPORT" ? "RESOLVED_BLOCK"
: decision.decision === "PROCEED_WITH_EDD" ? "RESOLVED_EDD"
: "RESOLVED_PROCEED",
resolution: decision,
};
}
Why the escalation is not a delay penalty
The instinct is to treat the compliance queue as friction to route around. Two facts reverse that instinct:
- The cases that get escalated are the cases that would have become complaints or SARs. A face match in the manual band is either a genuine client whose insurer must not decline, or someone using another person's documents — which is fraud and possibly a reportable matter. Guessing is a coin flip with a 50% downside.
- A well-written escalation that comes back in 24 hours is faster than a case that bounces. A clean referral — facts, evidence refs, the specific question asked — is resolved in one pass. An informal "hey can you check this?" to the compliance officer's phone generates two days of back-and-forth.
The referral summary is a skill worth teaching: state the facts, ask a specific question, do not state your conclusion. "The client's face match scored 0.78, which is inside the review band. Their HKID is valid to 2029 and the name matches. Is this a genuine match we should accept, or do you want additional ID?" is a referral. "The client looks fake to me" is not.
Record-Keeping Obligations
In practice: every KYC artefact — the ID images, the address proof, the screening result, the escalation, the decision, the liveness frames — is stored in Hong Kong with a hash and a timestamp, and is kept for five years after the relationship ends or the policy ends, whichever is later. When the compliance officer asks for the file, the agent clicks one button.
What is kept, and for how long
| Record | Retention | Why |
|---|---|---|
| Identity document copies | 5 years after the end of the relationship or the policy | AMLO customer due diligence record-keeping |
| Address proof copies | Same | Part of CDD |
| Health disclosure (Lesson 08) | Same, plus any longer period the carrier requires | Claim defence; mis-sale investigation |
| Application + proposal + signed forms + signature evidence | Same | Contract formation evidence |
| Screening results (including cleared) | Same | Proving the check was done, including false positives |
| Escalations and four-eyes decisions | Same | Proving the review happened and who did it |
| Liveness frames / video snippets | Per the carrier's policy; typically shorter than the application record | Biometric data — minimise retention; a video of a person's face is more sensitive than a document scan |
| Source-of-funds documentation | 5 years after the policy ends | Enhanced DD for high risk |
| Suspension / adverse media reports reviewed | Same as screening | Part of the disposition record |
The two rows that agents get wrong are the liveness video (agents think "keep everything" and store biometric footage for five years, which is disproportionate and raises its own PDPO question) and cleared screening hits (agents think a false positive needs no record, when in fact the record is the only proof you adjudicated rather than ignored it).
The storage and retrieval shape
export interface KycEvidenceBundle {
bundleId: string;
caseId: string;
createdAt: string;
retentionUntil: string; // computed, never set by hand
retentionBasis: "RELATIONSHIP_END_PLUS_5Y" | "POLICY_END_PLUS_5Y" | "LATER_OF_THE_TWO_PLUS_5Y";
residency: "HONG_KONG"; // enforced; cross-border transfer needs a separate basis
encryption: { atRest: "AES-256-GCM"; inTransit: "TLS1.3"; keysIn: "HK-managed KMS" };
items: KycEvidenceItem[];
manifestHash: string; // over all item hashes; the tamper-evidence anchor
legalHold: boolean; // if true, deletion is suspended
}
export interface KycEvidenceItem {
itemId: string;
type: "ID_FRONT" | "ID_BACK" | "ADDRESS_PROOF" | "HEALTH_DISCLOSURE" | "SIGNED_FORM"
| "LIVENESS_FRAME" | "SCREENING_RESULT" | "ESCALATION" | "EDD_FILE";
storageKey: string; // hk-resident object store; never a public URL
sha256: string;
mimeType: string;
capturedAt: string;
capturedBy: string; // agent or system; "client portal upload" is a distinct actor
containsBiometric: boolean; // liveness frames ⇒ true ⇒ minimisation rules apply
maskedViewData: Record<string, string>; // what the agent UI is allowed to display
}
export function retentionUntil(input: {
relationshipEndedAt?: string | null;
policyEndedAt?: string | null;
relationshipStart: string;
policyEnd?: string | null;
}): { retentionUntil: string; basis: KycEvidenceBundle["retentionBasis"] } {
// The retention clock is the LATER of the two end events, plus five years.
const candidates: Date[] = [];
if (input.relationshipEndedAt) candidates.push(new Date(input.relationshipEndedAt));
if (input.policyEndedAt) candidates.push(new Date(input.policyEndedAt));
// If neither has ended yet, the clock has not started — retention runs from the future end event.
const latestEnd = candidates.length ? new Date(Math.max(...candidates.map((d) => d.getTime()))) : null;
const basis: KycEvidenceBundle["retentionBasis"] = latestEnd
? (input.relationshipEndedAt && input.policyEndedAt ? "LATER_OF_THE_TWO_PLUS_5Y"
: input.policyEndedAt ? "POLICY_END_PLUS_5Y" : "RELATIONSHIP_END_PLUS_5Y")
: "RELATIONSHIP_END_PLUS_5Y";
// Until then, keep it as long as the live relationship requires.
const anchor = latestEnd ?? new Date();
return { retentionUntil: addYears(anchor, 5).toISOString(), basis };
}
export async function retrieveForExamination(bundleId: string, requester: {
complianceOfficerId: string; purpose: "IA_EXAMINATION" | "INTERNAL_AUDIT" | "SAR_SUPPORT" | "CLAIM_INVESTIGATION";
}): Promise<KycEvidenceBundle> {
// Access is logged; it is not an implicit read.
await auditLog.record({ bundleId, requester, at: new Date().toISOString(), action: "RETRIEVE" });
const bundle = await store.load(bundleId);
verifyManifestHash(bundle); // if this fails, the bundle is corrupt ⇒ escalate, do not return
return bundle;
}
The PDPO angle deserves one paragraph of its own, because it constrains what a POS may do that a pure compliance design might ignore. Under the PDPO, collecting more than is necessary is itself a breach. That gives concrete rules: collect the address proof that the carrier requires, no more; do not retain a passport page the application did not need; keep liveness frames only as long as the challenge result is a live risk; and give the client a real answer when they ask "what do you hold on me and for how long", including how to request access and correction under the PDPO's data-access and correction rights.
flowchart TD
A[Client identity presented<br/>in person or via portal] --> B{KYC risk profile}
B -->|Standard| C[Identity verification]
B -->|Elevated| Z[Enhanced due diligence<br/>source of wealth + senior approval]
C --> C1[Capture ID document<br/>HKID / passport / travel permit]
C1 --> C2[Document OCR<br/>+ check digit + authenticity]
C2 --> C3{Document checks pass?}
C3 -->|No — expired / checksum| C4[FAIL: ask client to correct<br/>never auto-correct]
C3 -->|No — tamper signal| Z2[ESCALATE to MLRO]
C3 -->|Yes| C4b[Read back critical fields<br/>agent confirms vs card]
C4b --> C5[Liveness detection<br/>challenge + spoof score]
C5 --> C6{Face match}
C6 -->|>= 0.85 auto-pass| D[Address verification]
C6 -->|0.70 - 0.85 manual band| Z3[Four-eyes review]
C6 -->|< 0.70| Z3
C5 -->|liveness failed| C7[FAIL + route to branch<br/>no override]
D --> D1[Collect address proof<br/>bank stmt / utility / tenancy]
D1 --> D2{Recency <= 3 months?<br/>name + address match?}
D2 -->|No| D3[FAIL: request recent proof]
D2 -->|Yes — third party?| Z4[Third-party address:<br/>documented reason + consent<br/>+ confirm call]
D2 -->|Yes — own address| E
Z --> Z5[EDD file compiled]
Z5 --> Z6[Senior approval<br/>not the originating agent]
Z6 --> E
Z2 --> Z5
Z3 --> Z5
E[Sanctions / PEP / adverse media screening<br/>proposer, assured, beneficiary, trustee] --> F{Result}
F -->|Clear| G[Record CLEAR<br/>include any false-positive reasoning]
F -->|PEP true match| Z
F -->|Adverse media| Z
F -->|Fuzzy sanctions| Z2
F -->|True sanctions match| H[BLOCK + report to JFIU<br/>do not tip off client]
F -->|False positive| G
G --> I[Bundle sealed<br/>manifest hash + retention clock]
I --> J[Application released to submission<br/>KYC case closed, monitoring continues]
H --> K[Case closed<br/>no service provided]
Z6 --> I
Key Takeaways
- The basis is Cap. 615 (AMLO) and its General Regulation, implementing FATF, supervised by the IA — not internal company preference. "I need a bank statement" is a statutory requirement the agent can explain in one sentence.
- Insurance AML is mostly about getting money out. Refuse third-party payment, require the payment account in the proposer's own name, capture source of funds, and watch for rapid surrender after inception.
- Identity verification and address verification are separate obligations. A valid HKID proves who the person is; a bank statement or utility bill within three months proves where they live. The address printed on the HKID is not address proof. A third-party residential address requires a documented reason, the third party's written consent, and a confirmation call — and often the third party's address becomes the mailing address, so it must be said out loud rather than buried. An unsubstantiated third-party address escalates.
- OCR may pre-fill; it may not decide. Auto-accept is limited to high-confidence, check-digit-validated, read-back-confirmed fields. OCR values are bound to the source image hash and flagged stale when the source is replaced.
- A checksum failure on an HKID is a "read it again", never an auto-correction. Auto-fixing a typo into a valid but wrong number creates an identity mismatch later that is worse than the original error.
- Liveness has no override path in the system, by construction. Face match has a manual band; liveness failures are routing decisions (branch visit), not obstacles to be coached past.
- A sanctions true match has exactly one disposition: BLOCK_AND_REPORT, decided automatically or by the MLRO, with no agent-clearing path. PEP is not a block — it is EDD with senior approval and ongoing monitoring. A false positive must be documented as considered, not dropped.
- Four-eyes is data, not advice. The originating agent cannot decide their own escalation, a minimum-length rationale is required, and a sanctions escalation can only end in PROCEED_STANDARD or BLOCK_AND_REPORT.
- Record-keeping is five years after the later of relationship end or policy end, in Hong Kong, with hashes and a manifest — and biometric liveness frames are minimised rather than kept for five years. A cleared screening hit is kept precisely because "we checked and dismissed it" must be provable.