1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
//! Canonical Novu workflow trigger identifiers — the single Rust-side source of truth.
//!
//! A value here must match the workflow's trigger identifier in the notification center
//! **character for character**; an unknown identifier yields HTTP 422
//! `workflow_not_found`. The Java sender (`cex-keycloak`) and the notification service
//! (`cex-notification`) keep mirrored constants; all three must agree.
//!
//! Every identifier is the center's kebab-case slug. That is what the center generates
//! when a workflow is created and what it matches a trigger against; the
//! `Pascal_Snake_Case` text is only the workflow's display *name*. These constants used to
//! carry the names, so 53 of the 56 workflows here could never be triggered at all — each
//! answered 422 and was logged as one undelivered notification, which reads like an outage
//! rather than a typo, and nothing else gave it away.
//!
//! **Copy the identifier from the center; never derive it from the name.** It is not
//! reliably derivable: `Admin_Fiat_Unmatched_Deposit` is `fiat-unmatched-deposit`, and
//! `2FA_Enabled` is `2-fa-enabled`, not `2fa-enabled`. Add the constant here too, so no
//! service ever spells one out inline.
// --- Identity & security -------------------------------------------------------
/// Sensitive-action confirmation OTP (withdraw / api-key / 2FA-method change).
pub const ACTION_CONFIRMATION: &str = "action-confirmation";
/// Security alert when the account's second-factor method changes.
pub const SECURITY_TWO_FACTOR_CHANGED: &str = "security-two-factor-changed";
/// Two-factor authentication was turned on.
pub const TWO_FACTOR_ENABLED: &str = "2-fa-enabled";
/// Two-factor authentication was turned off, or a reset was requested.
///
/// The two are one message on purpose: from the account owner's side both mean "the second
/// factor protecting this account is no longer what it was", and that is the sentence
/// somebody who did not do it needs to read.
pub const TWO_FACTOR_DISABLED_RESET: &str = "2-fa-disabled-reset";
/// A sign-in from a device this member has not used before.
pub const NEW_DEVICE_LOGIN: &str = "new-device-login";
/// A sign-in from a country the member has not signed in from before, or one that could
/// not plausibly follow their previous sign-in.
pub const NEW_IP_LOCATION_LOGIN: &str = "new-ip-location-login";
/// Pre-registration email verification OTP.
pub const REGISTER_EMAIL_VERIFICATION: &str = "register-email-verification";
/// Pre-registration phone verification OTP.
pub const REGISTER_PHONE_VERIFICATION: &str = "register-phone-verification";
// --- Identity verification (KYC) --------------------------------------------------
//
// Driven by the case log in cex-compliance: one workflow per fact the log can record, so
// which message a member gets is decided by the same event that moved their case.
//
// application received → under review → approved
// ↘ additional document requested (resubmit)
// ↘ rejected (no retry path)
/// The member entered verification; the case now exists.
pub const KYC_APPLICATION_RECEIVED: &str = "kyc-application-received";
/// The case is with the provider or a compliance officer, awaiting a verdict.
pub const KYC_UNDER_REVIEW: &str = "kyc-under-review";
/// Verification succeeded.
pub const KYC_APPROVED: &str = "kyc-approved";
/// Rejected with **no** retry path — the member cannot fix this by resubmitting.
pub const KYC_REJECTED: &str = "kyc-rejected";
/// A nudge to a member who started verification and stopped, sent once per member and
/// track. Only for states where the next move is theirs — never for a case waiting on us.
pub const KYC_REMINDER: &str = "kyc-reminder";
/// Rejected **but retryable**: the provider asked for a document again (unreadable scan,
/// bad selfie, missing proof of address). A distinct message from [`KYC_REJECTED`]
/// because the member is being asked to act, not told the answer is no.
pub const KYC_ADDITIONAL_DOCUMENT_REQUESTED: &str = "kyc-additional-document-requested";
// --- Account standing ------------------------------------------------------------
//
// What the member may do, and who changed it. Distinct from KYC: verification is about
// who they are, these are about what the platform currently permits.
/// The account was disabled in Keycloak — the member cannot sign in.
///
/// Keycloak's `enabled` flag is the single source: it is what actually stops them, so a
/// freeze announced from anywhere else would promise an enforcement the platform does not
/// make. A KYC hold suspends *verification*, which is a different thing and stays silent.
pub const ACCOUNT_FROZEN: &str = "account-frozen";
/// The account was re-enabled in Keycloak.
pub const ACCOUNT_REACTIVATED: &str = "account-reactivated";
/// A specific permission was withdrawn (trading, crypto or fiat withdrawal) while the
/// account otherwise stays open. Narrower than [`ACCOUNT_FROZEN`], which suspends
/// everything gated on verification.
pub const ACCOUNT_RESTRICTED: &str = "account-restricted";
/// A withdrawal was requested from an unfamiliar device shortly after the credentials
/// changed — the shape of an account takeover. **Unwired:** cex-tx-risk-manager scores
/// `RECENT_PASSWORD_RESET`, `RECENT_2FA_CHANGE` and `NEW_DEVICE` independently, but nothing
/// composes them into a takeover verdict, and inventing that rule is a risk-policy decision
/// rather than a notification one.
pub const ACCOUNT_TAKEOVER: &str = "account-takeover";
/// The account was closed, by the member or by the platform. **Unwired:** no closure or
/// off-boarding flow exists in the platform — there is no endpoint, no status and no job
/// that closes an account.
pub const ACCOUNT_CLOSED: &str = "account-closed";
// --- Trading -----------------------------------------------------------------------
//
// Driven by the matching engine's own order-event stream, consumed by cex-history. Unlike
// the rest of this registry these scale with trading volume: an active trader produces
// hundreds a day and one resting order can produce dozens of partial fills. Configure the
// workflows accordingly — in-app rather than email/SMS unless the member opts in.
/// A new order passed validation and rests on the book.
pub const TRADE_ORDER_CREATED: &str = "trade-order-created";
/// An order matched in part; the remainder is still working.
pub const TRADE_ORDER_PARTIALLY_FILLED: &str = "trade-order-partially-filled";
/// An order matched in full and is closed.
pub const TRADE_ORDER_FILLED: &str = "trade-order-filled";
/// An order was cancelled by the member or by an admin. Distinct from expiry, which is an
/// order dying by its own time-in-force rules rather than anyone cancelling it.
pub const TRADE_ORDER_CANCELLED: &str = "trade-order-cancelled";
/// A stop or trigger order reached its trigger price. **Unwired:** the matching engine has
/// no stop orders — `OrderType` is `LIMIT` or `MARKET` only, and there is no trigger price
/// anywhere in it. Wiring this needs the engine feature first.
pub const TRADE_ORDER_TRIGGERED: &str = "trade-order-triggered";
/// Suspected wash trading, spoofing or market manipulation. **Unwired:** the engine
/// *prevents* self-trades per order (`RejectReason::SelfTradePrevented`) but nothing looks
/// for the pattern, and cex-trade-risk-manager is a pre-trade filter chain (balance, KYC,
/// notional, open orders) with no abuse detection. Deciding what counts as abuse is a
/// surveillance rule, not a notification one.
pub const TRADE_ABUSE: &str = "trade-abuse";
// --- Fiat withdrawal ------------------------------------------------------------
//
// The lifecycle, and which service owns each transition:
// requested (ledger) → approved (ledger) → sent to bank (ledger)
// → completed (history) … the bank settled it
// → failed (history) → failed+refunded (ledger) … the bank returned it
/// The withdrawal request was accepted into the system and the balance reserved.
pub const FIAT_WITHDRAWAL_REQUESTED: &str = "fiat-withdrawal-requested";
/// An admin (or the automated decision) approved the request; the payout is next.
pub const FIAT_WITHDRAWAL_APPROVED: &str = "fiat-withdrawal-approved";
/// The bank accepted the payout instruction; settlement is now the bank's move.
pub const FIAT_WITHDRAWAL_SENT_TO_BANK: &str = "fiat-withdrawal-sent-to-bank";
/// The bank settled the transfer — the withdrawal is closed.
pub const FIAT_WITHDRAWAL_COMPLETED: &str = "fiat-withdrawal-completed";
/// The bank reported the payout as failed. The balance is **still reserved** at this
/// point — it goes back to admin review, so this is the "we are on it" message, not a
/// refund confirmation. The refund, if it happens, is a separate event below.
pub const FIAT_WITHDRAWAL_FAILED: &str = "fiat-withdrawal-failed";
/// A fiat withdrawal the bank returned or an admin rejected; the reserved balance
/// has been released back to available. Triggered from the ledger's withdraw
/// decision — the point where the release event is actually written — so the
/// message is never sent for a refund that did not happen.
pub const FIAT_WITHDRAWAL_FAILED_REFUNDED: &str = "fiat-withdrawal-failed-refunded";
// --- Fiat deposit ---------------------------------------------------------------
/// A bank movement was matched to the depositor and the amount is now on their
/// available balance.
pub const FIAT_DEPOSIT_CREDITED: &str = "fiat-deposit-credited";
/// A deposit an admin rejected or returned to the sender. Only reaches a customer when
/// the movement was matched to one — an unmatched rejection has no recipient and is
/// [`ADMIN_FIAT_UNMATCHED_DEPOSIT`]'s story instead.
pub const FIAT_DEPOSIT_REJECTED: &str = "fiat-deposit-rejected";
// --- Operations channels ---------------------------------------------------------
//
// Addressed to an `AdminChannel`, not to a person: routed to a topic whose membership
// lives in the notification center. The `Admin_` prefix in the identifier is the reminder
// that no customer ever receives these — which is also why they can fire at moments where
// no member has been identified yet.
/// A bank movement was ingested from the statement, before anything is known about who
/// sent it. Operations sees the platform's incoming flow as it lands.
pub const ADMIN_FIAT_DEPOSIT_DETECTED: &str = "fiat-deposit-detected";
/// An incoming amount held because the sender's name/IBAN did not match a member.
/// By definition no member was matched, so there is no subscriber to send to.
pub const ADMIN_FIAT_UNMATCHED_DEPOSIT: &str = "fiat-unmatched-deposit";
// --- Registered in the notification center, no producer in the platform yet ------
//
// These exist as workflows but nothing triggers them, because the code has no point
// that means what they describe. Listed so the registry mirrors the notification
// center rather than silently diverging from it; delete or wire them deliberately.
/// A corporate onboarding application was received. **Unwired:** there is no KYB flow.
/// The only corporate concept in the codebase is the MKK investor *kind*, which is a
/// registry filing for an already-verified member, not an application to onboard.
pub const KYB_APPLICATION_RECEIVED: &str = "kyb-application-received";
/// A deposit held for risk/limit review. **Unwired:** the fiat deposit path has no
/// risk or limit gate — everything that reaches `PENDING_ADMIN` gets there through
/// identity matching, which is [`ADMIN_FIAT_UNMATCHED_DEPOSIT`].
pub const FIAT_DEPOSIT_MANUAL_REVIEW: &str = "fiat-deposit-manual-review";
// --- User bank accounts --------------------------------------------------------
/// A member added a bank account as a payout destination.
pub const BANK_ACCOUNT_ADDED: &str = "bank-account-added";
/// Several consecutive authentication failures were detected for a member.
pub const FAILED_LOGIN_WARNING: &str = "failed-login-warning";
/// An official legal or regulatory information request was recorded.
pub const LEGAL_REQUEST_RECEIVED: &str = "legal-request-received";
/// A member's password was changed successfully.
pub const PASSWORD_CHANGE: &str = "password-change";
/// A member signed in successfully.
pub const SUCCESSFUL_LOGIN: &str = "successful-login";
/// A member's withdrawal address whitelist was changed.
pub const WHITELIST_ADDRESS_CHANGE: &str = "whitelist-address-change";
pub const CRYPTO_ADDRESS_ADDED: &str = "crypto-address-added";
pub const CRYPTO_ADDRESS_DELETED: &str = "crypto-address-deleted";
pub const CRYPTO_DEPOSIT_CONFIRMED: &str = "crypto-deposit-confirmed";
pub const CRYPTO_DEPOSIT_CREDITED: &str = "crypto-deposit-credited";
pub const CRYPTO_DEPOSIT_DETECTED: &str = "crypto-deposit-detected";
pub const CRYPTO_DEPOSIT_MANUAL_REVIEW: &str = "crypto-deposit-manual-review";
pub const CRYPTO_DEPOSIT_UNCREDITED: &str = "crypto-deposit-uncredited";
pub const CRYPTO_WITHDRAWAL_APPROVED: &str = "crypto-withdrawal-approved";
pub const CRYPTO_WITHDRAWAL_BROADCASTED: &str = "crypto-withdrawal-broadcasted";
pub const CRYPTO_WITHDRAWAL_COMPLETED: &str = "crypto-withdrawal-completed";
pub const CRYPTO_WITHDRAWAL_FAILED_REJECTED: &str = "crypto-withdrawal-failed-rejected";
pub const CRYPTO_WITHDRAWAL_NO_TX: &str = "crypto-withdrawal-no-tx";
pub const CRYPTO_WITHDRAWAL_REQUESTED: &str = "crypto-withdrawal-requested";