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:
ThresholdDecoratorfires when a transfer's amount meets or exceedsRules.TravelRuleFullThreshold(the HKD 8,000 line reused as the suspicious-amount cutoff).VelocityDecoratorfires when a subject's transfer count inside the rolling windowRules.VelocityWindowSecondsreachesRules.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 toRESOLVED_APPROVED. No follow-on action — the case is closed benign.ESCALATED: flag transitions toPENDING_OFFICER. The reviewer's evidence hash is recorded; the resolution is stampedESCALATEDpending the officer's terminal call.BLOCKED: flag transitions toRESOLVED_BLOCKED, andx/kyc.Keeper.Blacklist(flag.Subject, "flag_blocked", "flag/<id>")is invoked. Theevidence_refis 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. OnlyAPPROVEDandBLOCKEDare valid terminal decisions. - Decision dispatch:
APPROVED: flag transitions toRESOLVED_APPROVED. The reviewer's prior escalation is overridden; the case closes benign on officer judgment.BLOCKED: flag transitions toRESOLVED_BLOCKEDand calls through toBlacklistidentically to the reviewer-blocked path. The evidence chain is stillflag/<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:
- Emission:
Flag { status: EMITTED, decorator, subject, ..., emitted_at }written by the AnteHandler;AuditEntry { tx_type: "threshold_flag" | "velocity_flag" | ... }appended withflag_idin metadata. - Review:
EventFlagResolved(for terminal decisions) orEventFlagEscalated(for escalations) emitted onMsgReviewFlag;AuditEntry { tx_type: "flag_review" }with decision and resolver address. - Escalation:
EventFlagEscalatedemitted onMsgEscalateFlag;AuditEntry { tx_type: "flag_escalate" }. - Officer close:
EventFlagResolvedonMsgCloseFlag;AuditEntry { tx_type: "flag_close" }. - Blacklist side-effect (if any
BLOCKEDdecision):EventLevelChanged { new_level: LEVEL_BLACKLISTED, reason: "flag_blocked", evidence_ref: "flag/<id>" }fromx/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 object | On reviewer APPROVED | On reviewer ESCALATED | On reviewer BLOCKED | On officer APPROVED | On officer BLOCKED |
|---|---|---|---|---|---|
Flag[id].status | RESOLVED_APPROVED | PENDING_OFFICER | RESOLVED_BLOCKED | RESOLVED_APPROVED | RESOLVED_BLOCKED |
Flag[id].resolution | APPROVED | ESCALATED | BLOCKED | APPROVED | BLOCKED |
Flag[id].reviewer_evidence_hash | recorded | recorded | recorded | preserved | preserved |
Flag[id].officer_evidence_hash | — | — | — | recorded | recorded |
x/kyc.Identity[subject].level | unchanged | unchanged | LEVEL_BLACKLISTED | unchanged | LEVEL_BLACKLISTED |
AuditLog entry | flag_review | flag_review (ESCALATED) or flag_escalate | flag_review | flag_close | flag_close |
EventFlagResolved | emitted | — | emitted | emitted | emitted |
EventFlagEscalated | — | emitted | — | — | — |
EventLevelChanged | — | — | emitted | — | emitted |