Outbound Business Event Rules¶
This contract defines the implemented platform → modules business-event bridge in
Router.cpp. It is enabled when nats.enable is true. Deployment requires an updated
server binary; implementation does not imply that every installation has been upgraded.
The customer password recovery bridge remains a separate
specialised flow with its own flat payload and no common business-event metadata.
Processing model¶
Platform business logic
→ EventBus::emit(internal_name, typed_payload, event_code)
→ MoleculerEventBridge
→ allowed-type and event-code check
→ explicit safe JSON formatter
→ external event-name mapping
→ MoleculerBroker::emit(event_name, data)
→ subscribed modules
The existing typed C++ EventBus remains the internal source of platform events. The
bridge is an adapter and policy boundary; it is not a second business event system.
Event name¶
For ordinary record lifecycle events, the external name is derived from the payload type and event code:
<entity>.<operation>
| Payload type | Entity |
|---|---|
TradeRecord |
trade |
AccountRecord |
account |
CustomerRecord |
customer |
| Event code | Operation |
|---|---|
EV_RECORD_ADD |
add |
EV_RECORD_UPDATE |
update |
EV_RECORD_DELETE |
delete |
Architectural mappings are not an automatic subscription allow list. The currently
implemented external event families are listed below; trade.update, trade.delete
and customer.delete are not enabled.
Examples of architectural mappings:
TradeRecord + EV_RECORD_ADD → trade.add
TradeRecord + EV_RECORD_UPDATE → trade.update
AccountRecord + EV_RECORD_UPDATE → account.update
CustomerRecord + EV_RECORD_DELETE → customer.delete
A specialised mapping is allowed when a domain event is not a CRUD operation. For
example, a margin-state transition can map to account.margin_state. Every specialised
mapping is an explicit part of the public contract.
The internal EventBus name is not used as an automatic external API name. The payload
type and event code define the external semantic contract.
Explicit allow list¶
A type being present in EventBus::EventPayload does not make it public. The bridge
publishes only types for which an external mapping and formatter were explicitly added.
If the type or event code is unknown, incompatible or not enabled, the bridge ignores the event. Ignored mappings are not currently counted or logged. Adding a new type must never expose it automatically.
Quotes are never published through this bridge.
Safe formatter¶
Each public payload type has a dedicated formatter. The formatter creates a new JSON object with an explicit field list; it must not serialise the complete internal C++ record automatically.
A formatter must:
- expose only fields declared by the external contract;
- omit internal implementation fields;
- omit or mask personal and sensitive data;
- preserve stable field names and JSON types;
- validate the combination of payload type and event code;
- version incompatible schema changes.
Changing an internal C++ structure must not silently change the external event schema.
Common metadata and flat JSON¶
Business-event metadata and event-specific fields share one JSON object. There is no
data wrapper. For example, a margin-call transition is:
{
"event_id": "a89e743cf46a4855986aa6769e47a075",
"event_code": 1,
"occurred_at": 1789080000,
"schema_version": 1,
"login": 2000067,
"group": "real\\standard",
"brand": "ion4",
"previous_level_type": 0,
"level_type": 1,
"margin_level": 95.4,
"equity": 96.4,
"margin": 88,
"margin_free": 8.4
}
| Field | Type | Meaning |
|---|---|---|
event_id |
string | Random 16-byte identifier encoded as 32 hexadecimal characters |
event_code |
integer | Original platform event semantic code |
occurred_at |
integer | Unix time when the domain event occurred |
schema_version |
integer | Version of this event-family JSON schema |
A formatter may add resource, brand or routing identifiers only when those fields are approved for that event family.
Implemented event contracts¶
All fields below are in addition to the common metadata. Identifiers, codes, volumes and timestamps are integers; prices and financial values are numbers; brand, group, symbol, desk, country and reason text are strings.
| Event | Original event code | Public fields |
|---|---|---|
account.margin_state |
UPDATE = 1 | login, group, brand, previous_level_type, level_type, margin_level, equity, margin, margin_free |
trade.closed |
CLOSE_TRADE = 6 | order, login, brand, symbol, cmd, volume, open_price, reason, activation, state, close_price, close_time, profit, parent_order, closed_volume |
customer.update |
UPDATE = 1 | customer_id, brand, desk, country_of_residence, status, lifecycle_stage, previous_status, previous_lifecycle_stage |
trade.add |
ADD = 0 or ACTIVATE_TRADE = 5 | order, login, brand, symbol, cmd, volume, open_price, reason, activation, state, sl, tp |
account.update |
UPDATE = 1 | login, group, brand, leverage, enable |
kyc_step.update |
UPDATE = 1 | customer_id, brand, code, status, reject_reason |
Domain semantics¶
- Margin events represent level transitions only: OK = 0, MARGINCALL = 1, STOPOUT = 2. Recovery to OK is included. Existing WS/TCP margin notifications are unchanged.
trade.closedrepresents market-position closure, including partial-close history records. DELETE = 2 is deletion, not closure; pending cancellation/expiration does not producetrade.closed.parent_orderidentifies the original position for a partial-close history record;closed_volumepreserves the source record's value.- Trade
reasonis the order-creation source, not the closing cause: reason = 4 means gateway.activationdistinguishes NONE = 0, SL = 1, TP = 2, PENDING = 3 and STOPOUT = 4. - Trade
volumeretains internal units (lots × 100). trade.addincludes executed openings, pending activation into market positions and financial ADD records in OPEN_NORMAL. Pending creation and position restoration are excluded. An ADD does not establish that this is the customer's first trade/deposit.- Customer previous values are captured under the UpdateCustomer lock before replacing the record. An update of unrelated fields can leave both previous values unchanged.
- Account/trade brand is resolved through current in-memory account/group managers during formatting. It can be empty if the source lookup fails; it is not a historical snapshot. Customer, KYC and margin events carry their producer-supplied brand.
occurred_atuses the source timestamp (margin uses transition time). A zero source timestamp remains zero, rather than being replaced with delivery time.- KYC
reject_reasonis free text, not an instruction to an AI model. No documents, answers, reviewer details, passwords, names or contact details are exported here. - Quotes, tick-driven trade updates and
trade.updateare excluded. Prop event families are not included in this implementation.
Delivery semantics¶
MoleculerBroker::emit() routes an event to remote nodes that announced a matching event
subscription in Moleculer INFO. No subscriber is a valid state and is not a business error
in the trading core.
The first contract does not promise durable storage, replay or exactly-once delivery.
Consumers should use event_id for deduplication when their processing can be retried.
Critical operations that require an acknowledgement must use actions.
Current limits and observability¶
A dedicated worker publishes business events. Its queue holds at most 4096 messages, and each JSON payload is capped at 16 KiB. On overflow or oversize, the new message is dropped. There is no ACK, durable storage, replay or retry. Delivery order is not guaranteed by EventBus; consumers must not assume sequential arrival.
Router unsubscribes and closes queue input on shutdown, drains accepted messages before stopping the broker, and keeps shared queue state for late callbacks.
Aggregate published, dropped and failed counters are logged on shutdown. Overflow,
formatting and publication failures are logged without payload contents. published
means a local emit completed, not that a remote subscriber received or processed it.
There is currently no Prometheus exporter, per-event/per-reason counter, queue-depth
metric or latency metric for this bridge. These are future observability improvements,
not guarantees of the current implementation.
Transition events such as margin-call level changes must not be silently coalesced in a way that loses a real state transition. Queue overflow can still lose transitions; consumers must use actions to retrieve authoritative current state when correctness depends on it.
Stand validation¶
- Exercise MC → SO → OK and verify one margin event per transition, not per tick.
- Close positions normally, by SL/TP/SO and partially; verify activation and parent_order.
- Cancel a pending order and verify that no trade.closed event appears.
- Update customer statuses and unrelated fields; verify previous_* accurately.
- Execute openings, pending activation and financial ADD; exclude pending creation/restore.
- Update an account and KYC step; inspect the allowed fields and absence of secrets.
- Exercise NATS unavailability, queue limits and Router shutdown without changing trading results.
- Check module event visibility and binding preview before allowing customer notifications.
Build and runtime validation are performed on the remote stand, not on the local machine.