Skip to main content

Data Ownership

Status

Active

Date

2026-04-28

Owners

  • Platform Backend

Last Verified Commit

def491e5

Ownership Model

servers_v2 currently shares one PostgreSQL runtime, but logical ownership is still service-specific.

The practical rule is:

  • a service may read shared state when required for orchestration or projection
  • only the owner service may define the write semantics of its aggregate data
  • balance mutation remains exclusive to wallet_service

Owned Table Families And Aggregates

OwnerPrimary Data ResponsibilityNotes
player_serviceplayer auth/profile state, registration flow, player-owned outbox rows, message/common projectionsserves gateway-facing player queries
wallet_servicewallet idempotency, wallet outbox, balance mutation, approved deposit/withdraw side effects, balance logssingle money writer
rolling_servicerolling records, rolling inbox/outbox state, completion/cancel retry stateconsumes wallet events
promotion_servicecoupon usage, promotion configs, settlement projections, promotion_coupon_sagaconsumes wallet settlement events
game_serviceprovider callback transaction state and provider-specific integration recordscallback ownership stays local
admin_serviceoperational query models, top-info aggregation support, admin-facing config and control surfacesorchestrates but should not steal ownership
recon_serviceshooter_* table family: pushbullet, SMS, phone whitelist, recharge templates, device compatibilityextracted from middle_server
gatewayno business table ownershipedge only

Important Ownership Boundaries

  • wallet_service owns deposit approval and withdraw approval side effects even when initiated through admin_service or recon_service.
  • recon_service may persist matched order_id, review state, or sms_id, but not final balance mutation.
  • admin_service owns back-office compatibility and operator workflows, not domain balance logic.
  • player_service owns player registration semantics even though gateway exposes the public route surface.

Multi-Brand Scoping

Per ADR-009, every row in the table families below is scoped by brand_id:

  • player_service: player auth/profile, registration helpers, agent_setting, agent_domain, player-owned outbox rows, message/common projections
  • wallet_service: wallet_account, wallet_bucket, wallet_bucket_type, wallet_coupon_grant, wallet_bet_authorization, wallet_ledger, wallet_transfer, wallet_idempotency, wallet outbox, wallet_inbox, wallet_dead_letter; wallet_topology and wallet_policy documents are brand-scoped
  • rolling_service: rolling records, rolling inbox/outbox, completion/cancel retry state
  • promotion_service: coupon usage, promotion configs, settlement projections, promotion_coupon_saga
  • game_service: provider callback transaction state and provider-specific records (resolved from outbound account namespace {brand_code}_{account})
  • admin_service: per-brand operational config and per-brand control surfaces
  • recon_service: shooter_sms (brand_id populated from matched deposit) and player/deposit-referencing review-state rows. Operator-infrastructure tables (shooter_device, shooter_pushbullet, shooter_phone, shooter_template_recharge) stay brand-global

Brand-global aggregates (no brand_id):

  • brand and brand_config (owned by admin_service)
  • agent aggregate (owned by agent_service)
  • agent_brand join (write-owned by admin_service; read by agent_service only). No other service consults the allow list in this iteration; if a future agent-initiated wallet/promotion command needs to honor the allow list, it must read agent_brand through agent_service rather than directly. Migration-window note: between Phase 2 (0023b) and Phase 12 (0028), a Postgres BEFORE INSERT trigger on agent automatically inserts (agent_id, default_brand_id, 'enabled') into agent_brand so new agents created during the window are not orphaned. The trigger is removed once admin_service writes agent_brand at agent creation in Phase 12.
  • game provider credentials and integration secrets (owned by game_service)
  • recon operator infrastructure: shooter_device, shooter_pushbullet, shooter_phone, shooter_template_recharge (owned by recon_service)
  • infrastructure-level Redis keys, idempotency tokens, and shared rate limits

Cross-brand reads are forbidden in domain code paths. Admin-side aggregations across brands must opt in explicitly and must not produce money writes.

Brand-Scoped Table Inventory

This is the canonical, table-by-table inventory of every physical brand-relevant table (whether ORM-declared in servers_v2/shared/rgb_db/src/rgb_db/models/ or defined only in raw SQL inside an alembic migration) and its multi-brand disposition. The brand-isolation migration chain is:

  • 0022_create_brand_aggregates.py -- brand, brand_config, agent_brand machinery.
  • 0023_seed_default_brand.py -- single default brand row + agent_brand(agent_id, default_brand_id, 'enabled') for every pre-migration agent.
  • 0023b_install_agent_brand_autoseed_trigger.py -- BEFORE INSERT trigger on agent so any new agent created during the Phase 2-12 window picks up an agent_brand row automatically.
  • 0024a_add_brand_id_nullable.py -- adds brand_id (nullable) to every brand-scoped table.
  • 0024b_backfill_brand_id.py -- writes the default brand id into every existing row.
  • 0024c_set_brand_id_not_null.py -- flips the column to NOT NULL (every brand-scoped query post this point can rely on the column being present).
  • 0026_add_brand_id_fk.py -- adds FOREIGN KEY (brand_id) REFERENCES brand(brand_id) to every brand-scoped table.
  • 0027_brand_scoped_uniqueness.py -- replaces every legacy single- column unique index with a brand-scoped equivalent (see "Single- Column PKs Requiring Rewrite In 0027" below).
  • 0028_remove_agent_brand_autoseed_trigger.py -- drops the 0023b trigger now that admin_service writes agent_brand explicitly at agent creation in Phase 12.
  • 0029_admin_audit_table.py -- creates admin_audit for the P1-3 / P1-4 operator-id + money-write audit guarantees.
  • 0030_game_callback_dead_letter.py -- creates game_callback_dead_letter for brand-unresolved game-provider callback persistence (DLQ replayed via tools/multi_brand_backfill/replay_game_callback_dlq.py).
  • 0031_brand_scope_outbox_idempotency.py -- brand-scopes the cross-service outbox + idempotency uniqueness keys so the same request id / dedup key may legitimately appear under two brands.
  • 0032_admin_audit_append_only.py -- DB-level append-only triggers on admin_audit (see ADR-009 audit guarantees and the rollout runbook "Audit row tamper-evidence" entry).
  • 0033_recovery_audit_table.py -- creates recovery_audit for the brand-aware recovery flow's append-only audit trail.
  • 0034_fix_agent_brand_autoseed_conflict_target.py -- corrects the 0023b trigger's ON CONFLICT target so concurrent agent inserts no longer race on agent_brand insertion.
  • 0035_brand_scope_player_stat_day.py -- brand-scopes player_stat_day and provider_stat_day uniqueness so per-brand daily aggregations no longer collide on a shared (player_id, date) key.
  • 0036_extend_append_only_triggers.py -- extends the 0032 append-only trigger pattern to wallet_ledger (per-bucket money movement) and player_balance_log (legacy compatibility ledger), so an attacker with DB write access cannot rewrite financial history to cover fraud or hide adjustments. UPDATE/DELETE on a finalised row + TRUNCATE of either table now raise loud plpgsql exceptions.
  • 0037_relax_shooter_sms_brand_id.py -- shooter_sms.brand_id must be nullable because inbound recon SMS arrives via Pushbullet/ Telegram before any brand correlation is possible. Brand_id is populated at match-time from the matched player_deposit.brand_id, with assert_sms_brand_after_match (recon_store.py) raising loudly if the match-time UPDATE fails to set it. The audit baseline (docs/runbooks/multi-brand/2026-04-28-brand-id-insert-audit.md) documents the BRAND_NULLABLE_BY_DESIGN exemption for shooter_sms; new producers writing into shooter_sms must rely on the recon match-time runtime guard rather than a local classifier script.

The tools/multi_brand_backfill/ verification script iterates the inventory below and the migration chain above.

Brand-scoped (gain brand_id NOT NULL + FK to brand)

Player-owned aggregates:

  • player, player_login, player_register, player_register_tag, player_referral, player_message, player_message_read, player_note, player_notice, player_tag, player_tag_mapping, player_group
  • player_deposit, player_withdraw, player_usdt, player_withdraw_usdt, player_adjust, player_balance_log
  • player_wallet_limit (PK rewrite -- see below)
  • player_level, player_level_domain
  • player_rolling, player_rolling_log
  • player_cashback_log, player_lossback_log, player_lossback_sports_log, player_rebate_log, player_rebate_details, player_rebate_sports, player_rebate_sports_log, player_provider_stat_day, player_stat_acc, player_stat_day

Agent satellites (agent itself is brand-global; per-brand satellites carry brand_id):

  • agent_setting (PK rewrite -- see below), agent_domain, agent_announcement, agent_balance_log, agent_global_mail, agent_login, agent_messages, agent_notice, agent_provider_percentage, agent_provider_stat_day, agent_stat_acc, agent_stat_day, agent_withdraw, sub_agent_provider_stat_day

Promotion / event:

  • event, register_event, attendance_event, attendance_event_details, banner, message_template
  • cashback_config, lossback_config, rebate_config, rebate_stat_day, promotion, promotion_stat_day, coupon, coupon_condition, coupon_log, coupon_recycle_log

Banking / payment:

  • bank, bank_card, bank_card_group, bank_owner_color, bank_stat_day

Risk / safety:

  • ban, ip_blacklist

Crypto / USDT (per-player rows):

  • coin_transaction, usdt_stat_day

Provider transaction state (per-account namespaced by brand):

  • bti_balance_change, bti_commit_reserve, bti_credit, bti_debit_reserve, bti_reserve
  • ho_transaction
  • mg_account (PK rewrite -- see below), mg_transaction
  • wc_account (PK rewrite -- see below)
  • digitain_credit_batch, digitain_debit, digitain_rollback, digitain_token (PK rewrite -- see below), digitain_transaction

Provider stats (per-brand GGR / payout):

  • provider_stat_day, provider_ggr_stat

Bet domain:

  • bet, sports_bet, sports_bet_line, sports_branch_stat_day, sports_event_stat_day, sports_market_stat_day, sports_parlay_stat_day

I18n overrides:

  • i18n (PK rewrite -- see below)

Daebak password legacy (per-player):

  • daebak_email (PK rewrite -- see below), daebak_pwd, daebak_agent_pwd

Operations / async:

  • excel_download, online_stat, sms_log

Wallet aggregates (under wallet schema):

  • wallet_account, wallet_bucket, wallet_bucket_type, wallet_coupon_grant, wallet_bet_authorization, wallet_ledger, wallet_transfer, wallet_idempotency, wallet outbox, wallet_inbox, wallet_dead_letter, wallet_topology, wallet_policy

Recon (the brand-scoped subset only):

  • shooter_sms, plus any player- or deposit-referencing recon review-state row added in future migrations

Cross-service outbox / inbox / saga tables (carry brand_id so consumers can scope projections per brand):

  • player_outbox (owned by player_service)
  • rolling_outbox, rolling_inbox (owned by rolling_service)
  • agent_outbox (owned by agent_service)
  • promotion_inbox, promotion_coupon_saga (owned by promotion_service)
  • wallet outbox, wallet_inbox, wallet_dead_letter (already listed under wallet aggregates above)

Brand-global (no brand_id)

  • brand, brand_config, agent_brand (the brand machinery itself)
  • agent (per ADR-009; brand membership lives in agent_brand)
  • provider, provider_ggr_config, game (provider/game catalogs are shared across brands per ADR-009's "credentials are global" posture; per-brand variation lives in provider_stat_day rollups)
  • global_var (kept global for values that genuinely never differ; brand_config overrides for values that may)
  • withdraw_usdt_link (USDT chain configuration is infrastructure, not per-brand)
  • vera_ip, vera_sync (infrastructure / verification)
  • admin_log, admin_stat_acc, admin_stat_day (admin operates across all brands; admin entry surface is brand-global)
  • Recon operator infrastructure (per ADR-009): shooter_device, shooter_pushbullet, shooter_phone, shooter_template_recharge

Brand-global tables (intentional) — classification audit

Three tables surfaced during the CR-C-IM-3 audit as ambiguous (not listed in 0024a's RGB_SCHEMA_TABLES, ORM models present, no brand_id column). The decision for each is recorded here so future contributors do not re-litigate the call. The matching ORM models in servers_v2/shared/rgb_db/src/rgb_db/models/ carry a header comment pointing at this section.

TableDecisionRationale
bank_card_group_staffBrand-global (legacy / inactive)Per ADR-009 the staff identity surface was deleted at Phase 12. The ORM remains for migration completeness only — no code in servers_v2/ reads or writes the table. Adding brand_id would require schema work that supports a feature that does not exist. If a future iteration revives the staff-assignment workflow, that work MUST also brand-scope the table at the same time.
withdraw_usdt_linkBrand-global (infrastructure)The table holds the USDT chain endpoints (TRC-20 vs ERC-20 selection, gas defaults, Plisio link metadata) that are properties of the chain itself, not of a brand. Two brands sending USDT use the same chain endpoints; per-brand variation lives in brand_config for things like minimum-withdraw thresholds. INSERT/UPDATE happens at the database layer, not via the application — see the table comment.
provider_ggr_configBrand-global (provider catalog)Per the same ADR-009 policy that keeps provider and game global ("credentials and catalogs are global; per-brand variation lives in stats rollups"), the GGR invoice cadence config is a property of the provider's billing arrangement, not of any brand. Per-brand GGR numbers live in provider_ggr_stat (which IS brand-scoped).

If a future iteration determines any of these need brand scope, the process is: open a tracking issue, add the table to RGB_SCHEMA_TABLES in 0024a, write a follow-up migration 0039_add_brand_id_to_<table>.py, then add brand_id to the ORM and remove the corresponding row from this table.

Deleted by ADR-009 (not migrated)

  • bank_card_group_staff (staff identity removed)
  • Anything else discovered to depend exclusively on the deleted staff routes (audited at Phase 12)

Single-Column PKs Requiring Rewrite In 0027

The following tables have a single-column PK that must be replaced with a composite PK including brand_id (drop existing PK, add new composite PK after 0024c sets brand_id NOT NULL):

TableOld PKNew PK
agent_settingagent_id(agent_id, brand_id)
player_wallet_limitplayer_id(brand_id, player_id)
daebak_emailplayer_id(brand_id, player_id)
mg_accountaccount(brand_id, account)
wc_accountaccount(brand_id, account)
digitain_tokenaccount(brand_id, account)
i18ncode(brand_id, code)

Every other brand-scoped table uses a surrogate guid BIGINT PK that remains untouched; only uniqueness constraints change.

Legacy Residual Data Still Needing Explicit Ownership

The following legacy middle_server areas are still not fully mapped to a stable owner in servers_v2:

  • role and menu configuration
  • legacy system config editing
  • i18n management
  • web rules and FAQ/content tables
  • BI push data and notes
  • coin passthrough or exchange configuration

Those areas remain part of the full-cutover gap.