Plan: Bring Agent Balance Under the Single Money Writer
Status
proposed
Date
2026-05-05
Owners
- Platform Backend
- Wallet Domain
- Agent Domain
Problem
servers_v2/CLAUDE.md declares wallet_service as the 唯一资金写入者
(Single Money Writer) — every change to player money must flow through
the wallet topology bucket model. Agent money does not honor that
constraint today.
Concrete examples of agent-side money writes that bypass wallet_service:
servers_v2/agent_service/app/api/routes/withdraw.py— directUPDATE agent SET balance = ...after aSELECT ... FOR UPDATEon theagenttable. Idempotency is now Redis-side (see the recent withdraw idempotency change), but the write itself is still local.- Other agent-side balance mutations live next to commission/recon
flows in
agent_serviceandadmin_serviceand are similarly outside the wallet domain.
Consequences:
- Audit/recon gaps.
recon_serviceconsumes wallet events; agent balance changes never appear inwallet:events, so cross-checking an agent ledger requires a second, parallel pipeline. - Two truths for "balance." Player wallet topology is the policy surface for limits, holds, fail-closed semantics. Agent balance is a single column with no holds, no buckets, and no per-domain policies. Any future feature (agent rolling, agent freeze) has to re-implement what the topology already provides.
- Multi-writer risk. If agent commission accrual ever needs to
land in the same balance the withdraw handler is locking, the FOR
UPDATE inside
agent_servicemust coordinate with whatever other service writes it. Today there is no such other writer; the moment there is, we have a deadlock or a torn write.
Goal
Treat the agent balance as a wallet topology bucket owned by
wallet_service. All reads and writes go through wallet HTTP
endpoints + outbox events; agent_service retains only orchestration
(JWT, request shaping, business rules), not money state.
Non-Goals
- Changing how agent commissions are computed. The calculation logic
stays in
agent_service/admin_service; only the persistence and emission of money deltas moves. - Replacing the
agenttable column outright in one shot. The plan uses dual-write + cutover, not big-bang.
Phased Plan
Phase A — Topology & Contract
- Add an
agenttopology bucket family to wallet topology. Schema change inservers_v2/shared/rgb_db/migrations/versions/. The bucket key is(brand_id, agent_id). - Define wallet-domain commands:
POST /internal/wallet/agent/credit(request_id, agent_id, amount, reason, metadata)POST /internal/wallet/agent/debit(same shape)GET /internal/wallet/agent/{agent_id}/balance
- Add the
AgentBalanceChangedevent towallet:eventsper the contracts inservers_v2/shared/contracts/.
Phase B — Dual-Write Window
agent_servicecontinues to updateagent.balancedirectly and issues the matching wallet command in the same DB transaction (via outbox row, not synchronous HTTP).recon_serviceconsumes the new event and verifies theagent.balancecolumn matches the wallet bucket sum at fixed intervals. Mismatches alert; they do not fail closed yet.- Land the dual-write behind
RGB_AGENT_BALANCE_DUAL_WRITEenforcement modes —off/observe/enforce, mirroring the multi-brand rollout pattern from ADR-009.
Phase C — Authoritative Cutover
- After ≥30 days of
enforcewith zero recon mismatches: flip the read path.agent_servicereads balance from wallet, not from theagentrow. - The
agent.balancecolumn becomes a denormalized cache, written only by a wallet→agent backfill consumer for legacy callers that still SELECT it. Tracked separately like the player legacy balance columns are tracked inservers_v2/CLAUDE.mddata-ownership table.
Phase D — Column Removal
- Drop the legacy
agent.balancecolumn once no service reads it. - Remove the dual-write code from
agent_service.
Risks
- Wallet bucket count explosion. Adding agent buckets ~doubles the number of bucket rows. Confirm topology read paths stay under their performance budget before Phase A merges.
- Brand-scoping invariants. Agent buckets must be brand-scoped from
day one — agents in brand X must not see brand Y agent balance.
Aligns with the multi-brand event scoping plan
(
docs/plans/multi-brand/2026-05-05-event-brand-scoping.md). - Commission timing. Agent commission landings happen on a
scheduler today. Switching to wallet-domain writes means the
scheduler now produces outbox rows; idempotency keys must be stable
across reruns (use
(commission_period, agent_id)as the key).
Acceptance
- All agent balance mutations originate from
wallet_servicewrites. recon_serviceexposes a counter foragent_balance_recon_mismatchthat is at zero in production for 30+ consecutive days.rg "UPDATE agent SET balance"in the repo returns matches only inwallet_service(the topology compatibility writer) or in legacy archives.
References
servers_v2/CLAUDE.md— Single Money Writer principledocs/adr/ADR-005-wallet-topology-bucket-ledger-model.mddocs/adr/ADR-009-multi-brand-domain-routed-isolation.mdservers_v2/agent_service/app/api/routes/withdraw.py