Skip to content

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.closed represents market-position closure, including partial-close history records. DELETE = 2 is deletion, not closure; pending cancellation/expiration does not produce trade.closed. parent_order identifies the original position for a partial-close history record; closed_volume preserves the source record's value.
  • Trade reason is the order-creation source, not the closing cause: reason = 4 means gateway. activation distinguishes NONE = 0, SL = 1, TP = 2, PENDING = 3 and STOPOUT = 4.
  • Trade volume retains internal units (lots × 100).
  • trade.add includes 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_at uses the source timestamp (margin uses transition time). A zero source timestamp remains zero, rather than being replaced with delivery time.
  • KYC reject_reason is 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.update are 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

  1. Exercise MC → SO → OK and verify one margin event per transition, not per tick.
  2. Close positions normally, by SL/TP/SO and partially; verify activation and parent_order.
  3. Cancel a pending order and verify that no trade.closed event appears.
  4. Update customer statuses and unrelated fields; verify previous_* accurately.
  5. Execute openings, pending activation and financial ADD; exclude pending creation/restore.
  6. Update an account and KYC step; inspect the allowed fields and absence of secrets.
  7. Exercise NATS unavailability, queue limits and Router shutdown without changing trading results.
  8. 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.