Hkchain

Review and close a compliance flag

Question. A decorator in the AnteHandler chain flags a transfer for review — it was suspicious but not outright blocked. What happens to that flag next, who reviews it, what are the outcomes, and what evidence does the chain keep?

Short answer. Flags move through a two-tier workflow. A reviewer (issuer-bound sub-key, SUB_ROLE_REVIEWER) triages each flagged tx and picks one of three outcomes with MsgReviewFlag: APPROVED clears the flag; BLOCKED resolves the flag as blocked and blacklists the subject; ESCALATED passes the case to the officer queue. An officer (also issuer-bound, SUB_ROLE_OFFICER) then handles escalated flags with MsgCloseFlag, choosing APPROVED or BLOCKED. Every terminal decision carries an evidence_hash binding the on-chain state to the off-chain case file. BLOCKED at either tier routes through x/kyc.Keeper.Blacklist with flag/<id> as the evidence chain.

Why two tiers, not one

The reviewer and officer split matches standard AML/CFT practice — a first-line compliance specialist screens flags for obvious dispositions (benign pattern, clear violation) and escalates the judgment calls to the officer / MLRO who has senior authority and the mandate to consider wider context (SAR filing, cross-case patterns, jurisdictional rules).

Collapsing the two into one role would either overload reviewers with authority they shouldn't hold (e.g. terminal block decisions on ambiguous cases) or bottleneck everything on the officer. Keeping them separate lets volume flow through reviewers while officer attention is reserved for cases that warrant it.

The chain encodes this with two distinct sub-roles and two distinct message types. Neither can act outside their lane — reviewers cannot close PENDING_OFFICER flags; officers are not the first-line queue.

The flag lifecycle, step by step

0. Prerequisite: sub-roles are bound under an L5 issuer

Before anyone can review or close flags, the L5 licensed issuer (the primary key on the chain, e.g. HSBC's address) binds sub-keys with MsgBindSubRole:

MsgBindSubRole { parent: hsbc, sub_addr: reviewer_1, role: SUB_ROLE_REVIEWER }
MsgBindSubRole { parent: hsbc, sub_addr: officer_1, role: SUB_ROLE_OFFICER }

The bindings are checked at bind time: the parent must be at LEVEL_LICENSED_ISSUER, and the reverse-index constraint ensures a single sub-addr cannot belong to two parents. The sub-key pairs then sign review / close messages on behalf of the issuer's compliance team.

Revocation is MsgRevokeSubRole; effective next block, the sub-addr can no longer sign flag-workflow messages.

1. A decorator emits the flag

Flags arrive into the queue from the AnteHandler. Three decorators emit flags in MVP:

  • ThresholdDecorator fires when a transfer's amount meets or exceeds Rules.TravelRuleFullThreshold (the HKD 8,000 line reused as the suspicious-amount cutoff).
  • VelocityDecorator fires when a subject's transfer count inside the rolling window Rules.VelocityWindowSeconds reaches Rules.VelocityTxLimit.
  • A soft-sanctions decorator path (future expansion) would fire on hits against a watch-list that is strictly weaker than the hard blacklist — for investigation rather than automatic block.

Each emission allocates a fresh flag_id from the monotonic counter and writes:

Flag {
  id: 42,
  status: FLAG_STATUS_EMITTED,
  decorator: "threshold" | "velocity" | ...,
  subject: sender_addr,
  counterparty: recipient_addr,
  amount: coin,
  vasp: sender's_vasp_identifier,
  emitted_at: block_time,
}

The emission does not block the tx — flagging decorators log and flag, they do not reject. The transfer proceeds; the flag lands in the reviewer queue.

MVP: flagging decorators log without blocking, matching demo risk tolerance. finalProduct may tighten velocity / threshold to outright block for certain profile categories — that is a rule-tuning decision per HKMA guidance and is out of MVP scope.

2. The reviewer resolves the flag

The reviewer queue is Flags with status == EMITTED, filtered by the issuer's VASP. A reviewer loads the case off-chain (transaction context, counterparty history, any KYC notes the issuer holds) and picks one of three outcomes:

MsgReviewFlag {
  reviewer: reviewer_1,
  flag_id: 42,
  decision: FLAG_DECISION_APPROVED | BLOCKED | ESCALATED,
  reviewer_evidence_hash: "sha256:<case-file-hash>"
}

On chain:

  • Authority check: the reviewer sub-key must be bound, non-revoked, and of role SUB_ROLE_REVIEWER.
  • Status check: the flag must be in FLAG_STATUS_EMITTED. Flags already resolved or pending officer attention are not re-reviewable.
  • Decision dispatch:
    • APPROVED: flag transitions to RESOLVED_APPROVED. No follow-on action — the case is closed benign.
    • ESCALATED: flag transitions to PENDING_OFFICER. The reviewer's evidence hash is recorded; the resolution is stamped ESCALATED pending the officer's terminal call.
    • BLOCKED: flag transitions to RESOLVED_BLOCKED, and x/kyc.Keeper.Blacklist(flag.Subject, "flag_blocked", "flag/<id>") is invoked. The evidence_ref is the flag id — permanent on-chain pointer back to the case.

The reviewer carries enough authority to block directly for clear-cut cases (known sanctioned counterparty, obvious structured transfer). Escalation is for ambiguity.

3. The officer closes an escalated flag

When a flag is in PENDING_OFFICER, the officer takes over:

MsgCloseFlag {
  officer: officer_1,
  flag_id: 42,
  decision: FLAG_DECISION_APPROVED | BLOCKED,
  officer_evidence_hash: "sha256:<officer-case-file-hash>"
}

On chain:

  • Authority check: the officer sub-key must be bound, non-revoked, and of role SUB_ROLE_OFFICER.
  • Status check: the flag must be in FLAG_STATUS_PENDING_OFFICER. Flags in other states are not closable by this path.
  • Decision check: the officer cannot choose ESCALATED — the case has already escalated once; nowhere further to go. Only APPROVED and BLOCKED are valid terminal decisions.
  • Decision dispatch:
    • APPROVED: flag transitions to RESOLVED_APPROVED. The reviewer's prior escalation is overridden; the case closes benign on officer judgment.
    • BLOCKED: flag transitions to RESOLVED_BLOCKED and calls through to Blacklist identically to the reviewer-blocked path. The evidence chain is still flag/<id> — both the reviewer's and the officer's evidence hashes are retained on the flag record.

An escalated-then-blocked outcome produces a flag record carrying both reviewer_evidence_hash and officer_evidence_hash — the chain retains the complete adjudication trail even though only the officer's decision is terminal.

4. Optional: the reviewer escalates directly

MsgEscalateFlag is a convenience path equivalent to MsgReviewFlag { decision: ESCALATED } but carries an officer_note_hash pointing to an officer-targeted briefing document rather than a reviewer-general case note:

MsgEscalateFlag {
  reviewer: reviewer_1,
  flag_id: 42,
  officer_note_hash: "sha256:<officer-brief-hash>"
}

Effect on state is identical: EMITTED → PENDING_OFFICER, with Resolution = ESCALATED. The field name difference is the only audit signal — it distinguishes "reviewer took a generic escalation" from "reviewer attached a curated hand-off briefing."

What the workflow does not do

Auto-file SARs. When an officer closes a flag as BLOCKED, the chain blacklists the subject and emits events; it does not file a Suspicious Activity Report with JFIU or HKMA. SAR filing is off-chain through whatever channel regulators operate.

MVP: a SARFiledEvent is defined as a placeholder for the hook point — it is not emitted during the standard flow. finalProduct would emit on every officer BLOCKED decision (or on a separate officer action) with an opaque reference to the filed STR number. Full SAR integration is out of MVP scope.

Consult an AI triage layer. Reviewers are human in MVP. The SUB_ROLE_REVIEWER boundary is designed to be the future insertion point for an AI triage recommender — a model surfacing "low risk / likely benign" suggestions to the human reviewer, or eventually taking direct decisions on narrow, high-confidence categories under officer supervision. That evolution is documented in the forward-looking design notes; MVP uses human reviewers only, with the role boundary already in place.

Enforce VASP-scoped flag routing. Under MVP's single-tier assumption, any bound reviewer or officer can operate on any flag in the queue. finalProduct's two-tier model (L5 issuers and L4 VASPs both running review teams) requires per-VASP scoping — flags from customers of VASP-A only visible to VASP-A's reviewers. That extension is architecturally reserved in Flag.Vasp but not enforced in handler logic today.

MVP: any bound officer can close any PENDING_OFFICER flag. finalProduct scopes by VASP — a documented expansion path requiring per-VASP KYC-provider authority, Identity VASP-ownership semantics, and routing logic in flag handlers. Out of MVP scope.

Validate the evidence hash contents. The chain records the hash; it does not verify the off-chain case file exists, is current, or reflects what actually happened. Evidence integrity is a compliance-process concern outside the chain. What the chain guarantees is: once the hash is on chain, the file that hashes to it is the file that was recorded — no substitution possible.

The audit trail, end to end

For any flag, the chain preserves a structured record:

  1. Emission: Flag { status: EMITTED, decorator, subject, ..., emitted_at } written by the AnteHandler; AuditEntry { tx_type: "threshold_flag" | "velocity_flag" | ... } appended with flag_id in metadata.
  2. Review: EventFlagResolved (for terminal decisions) or EventFlagEscalated (for escalations) emitted on MsgReviewFlag; AuditEntry { tx_type: "flag_review" } with decision and resolver address.
  3. Escalation: EventFlagEscalated emitted on MsgEscalateFlag; AuditEntry { tx_type: "flag_escalate" }.
  4. Officer close: EventFlagResolved on MsgCloseFlag; AuditEntry { tx_type: "flag_close" }.
  5. Blacklist side-effect (if any BLOCKED decision): EventLevelChanged { new_level: LEVEL_BLACKLISTED, reason: "flag_blocked", evidence_ref: "flag/<id>" } from x/kyc.

A post-hoc reviewer can reconstruct, for any flag id: which decorator emitted it, at what block, against what subject and amount, who reviewed and what they decided, whether it escalated, who closed it and how, and — if blocked — the level transition that followed. The two evidence hashes (reviewer's and officer's, when both apply) anchor the on-chain state to the off-chain case file.

Edge cases and what happens

Reviewer tries to review an already-resolved flag

Rejected with an invalid-status error. A flag at RESOLVED_APPROVED / RESOLVED_BLOCKED is terminal; no further state transitions. Prevents accidental or malicious re-litigation of closed cases.

Officer tries to close a non-escalated flag

Rejected. The officer queue is PENDING_OFFICER only. Flags at EMITTED are reviewer territory; flags at RESOLVED_* are terminal.

Officer picks ESCALATED on a close

Rejected with an invalid-decision error. Escalation is not a terminal decision, and the case has no further tier to escalate to. The officer must pick APPROVED or BLOCKED.

Revoked sub-role tries to act

Rejected with a sub-role-revoked error. Revocation takes effect at the next block; a reviewer whose binding is revoked at block N cannot sign MsgReviewFlag at block N+1, regardless of whether they still hold the key.

Multiple flags on the same subject

Supported and common. A single subject can be flagged by threshold, velocity, and sanctions soft-hit decorators across different transfers. Each is its own Flag record with its own id, subject, and lifecycle. Resolving one does not affect the others.

Blocked-then-unblocked

Not directly supported. A RESOLVED_BLOCKED flag is terminal; the x/kyc.Blacklist write it triggers is also operationally one-way under MVP (governance-signed delisting is scoped out). Reverting requires a governance path; the flag record itself remains at RESOLVED_BLOCKED as the historical record of the decision.

Reviewer acts before the officer queue is drained (race)

Not a race — each flag has exactly one (parent, flag_id) binding and its transitions are serialized through the single-threaded state machine. A reviewer can only act on flags at EMITTED, so they cannot accidentally operate on a flag an officer is already handling.

Reviewer chooses BLOCKED for a clear-cut case

Supported and designed in. Not every flagged case requires officer attention — when the pattern is unambiguous (known sanctioned counterparty, obvious structured transfer), reviewer-level block is the correct resolution and triggers the same blacklist primitive an officer block would.

On-chain state summary

State objectOn reviewer APPROVEDOn reviewer ESCALATEDOn reviewer BLOCKEDOn officer APPROVEDOn officer BLOCKED
Flag[id].statusRESOLVED_APPROVEDPENDING_OFFICERRESOLVED_BLOCKEDRESOLVED_APPROVEDRESOLVED_BLOCKED
Flag[id].resolutionAPPROVEDESCALATEDBLOCKEDAPPROVEDBLOCKED
Flag[id].reviewer_evidence_hashrecordedrecordedrecordedpreservedpreserved
Flag[id].officer_evidence_hash———recordedrecorded
x/kyc.Identity[subject].levelunchangedunchangedLEVEL_BLACKLISTEDunchangedLEVEL_BLACKLISTED
AuditLog entryflag_reviewflag_review (ESCALATED) or flag_escalateflag_reviewflag_closeflag_close
EventFlagResolvedemitted—emittedemittedemitted
EventFlagEscalated—emitted———
EventLevelChanged——emitted—emitted