Gaming
Implement virtual-currency grants, purchases, burns, atomic trades, sources, sinks, and inventory compensation on Parix.
Overview
Use Parix as the value ledger behind a game economy when rewards, purchases, burns, and trades must remain correct under duplicate delivery and concurrent spending.
This guide models Gold as an integer currency in ledger 8001. A player receives Gold from a funded reward pool, spends Gold into the treasury, burns Gold into a sink, and pays another player plus a platform fee in one linked trade batch.
Parix records fungible value. Item ownership, item attributes, inventory slots, progression, matchmaking, and entitlement metadata remain in the game database.
Architecture and ownership
| Component | Owns | Does not own |
|---|---|---|
| Game server | Authentication, anti-cheat, reward eligibility, prices, trade rules, stable event IDs, sagas | Authoritative ledger mutation after Parix accepts a transfer |
| Parix | Currency accounts, transfer history, balance constraints, linked-batch atomicity | Inventory metadata, progression, matchmaking, or client session state |
| Inventory database | Item instances, ownership, equipment, quantities, trade state, idempotent inventory commits | Fungible-currency balance |
| Economy operations | Mint budgets, manual-grant approval, source/sink policy, reconciliation, incident response | Rewriting committed ledger history |
| Analytics/read models | Wallet display, transaction history, source/sink dashboards, economy forecasts | Authorization to mint, spend, or adjust value |
Only a trusted game service calls Parix. Never put a Parix API key or a direct ledger-write capability in a game client.
A linked Parix batch can make all ledger legs of a trade atomic. It cannot atomically commit the inventory database. Use an idempotent saga: record the trade intent, commit the ledger batch, commit inventory, and issue stable compensating transfers after a definitive inventory failure. If the inventory outcome is ambiguous, look it up before compensating.
Ledger model
Ledgers
| Ledger | Unit | Purpose |
|---|---|---|
8001 | Gold | Rewards, purchases, burns, player trades, and Gold-denominated fees |
8002 | Gems | Separate, non-interchangeable premium currency; use its own accounts and policy |
Use integer base units. If the UI displays fractions, scale them into integers before writing. Never use JavaScript floating-point values for ledger amounts. A conversion between Gold and Gems is a separately priced business operation, not a transfer between different-ledger accounts.
Accounts
The example treats player and reward-pool value as credit-normal: spendable value is credits_posted - debits_posted. history is flag 8; debits_must_not_exceed_credits is flag 2; together they are 10.
| Role | Example ID | Code | Flags | Balance meaning and control |
|---|---|---|---|---|
| Treasury/source | 810000000000000001 | 300 | 8 | Privileged source and purchase revenue; only reviewed server workflows may debit |
| Reward pool | 810000000000000002 | 200 | 10 | Pre-funded reward budget; cannot grant more than its posted credits |
| Player Alice wallet | 810000000000000003 | 100 | 10 | Alice's spendable Gold |
| Player Bob wallet | 810000000000000004 | 100 | 10 | Bob's spendable Gold |
| Currency sink | 810000000000000005 | 400 | 8 | Gold removed by crafting, penalties, expiry, or another defined sink |
Create one wallet account per player and currency. Keep player identity and the wallet-account mapping in the game database. Opaque TigerBeetle IDs and user-data fields must not contain player email, platform account name, device ID, or other personal data.
Transfer codes
| Code | Event | Debit | Credit |
|---|---|---|---|
10 | Fund reward pool | Treasury/source | Reward pool |
11 | Grant reward | Reward pool | Player wallet |
20 | Purchase | Player wallet | Treasury |
21 | Burn | Player wallet | Currency sink |
30 | Trade principal | Buyer wallet | Seller wallet |
31 | Trade platform fee | Buyer wallet | Treasury |
40 | Trade principal refund | Seller wallet | Buyer wallet |
41 | Trade fee refund | Treasury | Buyer wallet |
90 | Reviewed adjustment | Policy-defined | Policy-defined |
Do not overload one code for rewards, purchases, and administrative corrections. Codes are part of the operational audit vocabulary.
Invariants
| Invariant | Enforcement |
|---|---|
| A player cannot spend Gold they do not have | Apply debits_must_not_exceed_credits to every player wallet |
| A reward campaign cannot exceed its budget | Pre-fund a constrained reward-pool account and grant only from that account |
| A reward or purchase is applied once | Persist a stable transfer ID derived from the immutable game event before submission |
| Trade principal and fee settle together | Put both transfers in one batch; set linked on the first/non-final leg and omit it from the final leg |
| Currency is not mixed | Use accounts and transfers from exactly one ledger for each currency-denominated leg |
| Sources and sinks are explicit | Mint only through authorized treasury funding codes; burn only into defined sink accounts |
| Inventory is not inferred from currency | Treat the inventory database as authoritative and compensate ledger settlement when an inventory saga definitively fails |
| Test activity cannot contaminate production | Use separate Parix databases, credentials, IDs, reconciliation, and telemetry for test and production |
Economy flows
| Flow | Required sequence |
|---|---|
| Grant | Validate the reward event, load its persisted transfer ID, debit the funded reward pool, credit the player, inspect result array |
| Purchase | Lock or compare-and-set the purchase intent, debit the player, credit treasury, then commit the item entitlement idempotently |
| Burn | Validate the burn reason, debit the player, credit the named sink, retain the source event reference |
| Trade | Persist trade and leg IDs, submit principal plus fee as one linked batch, then commit inventory; compensate a definitive failure |
For a trade involving multiple fungible currencies, each leg uses accounts in that currency's ledger. A linked chain may include legs from different ledgers when every individual leg stays ledger-consistent. The game service remains responsible for the exchange rate and trade policy.
Before you begin
You need:
- a Parix database in Ready state and its immutable database UUID;
- a separate non-production database for development, automated tests, load tests, and economy simulations;
- integer units, ledger IDs, account codes, transfer codes, source/sink policy, and balance conventions approved by the economy team;
- stable IDs persisted for every reward, purchase, burn, trade leg, compensation, and manual adjustment;
- an OAuth session for CLI work and a specific-database API key for the trusted game service; and
- an idempotent inventory workflow with lookup and compensation behavior.
Choose the plan deliberately:
| Plan | Workload posture |
|---|---|
| Developer | Learning, SDK tests, and prototypes only; shared and quota-limited |
| Dedicated Single Node | Isolated development, staging, simulations, or non-HA work; not production |
| Production HA/Production 6 | Production game economies requiring a supported production topology |
| Enterprise | Contract-defined production topology, networking, compliance, or support |
All plans are gateway-only. Applications send HTTPS requests through Parix and do not connect to TigerBeetle replica addresses with the native protocol.
The public schema accepts at most 8,190 records or IDs in an array request. Active plan limits can be lower. Batch below the smaller limit, and never split one linked chain across requests.
On a shared Developer database, query_accounts and query_transfers require a ledger filter. Use ledger: 8001 in API/SDK query filters or --ledger 8001 with the CLI.
Dashboard walkthrough
- Select the non-production organization and database. Confirm Ready, the plan, database UUID, and environment-specific name.
- Review account, transfer, open-pending-transfer, event-per-request, read, and write limits shown for the database.
- Select Connect, create a Specific database API key for the server workload, and store the one-time secret outside source control.
- Open Query, select Query accounts, choose ledger
8001, and run a ledger-scoped smoke query. On Shared, the first query for an unseen external ledger can allocate its project-ledger mapping and consume quota, so use the planned test ledger rather than treating the query as side-effect-free. - Review Metrics after test traffic to confirm accepted writes, failures/denials, latency, and quota consumption available for the plan.
A Developer database exposes tenant-scoped quota and request telemetry. The image does not show player balances or game-specific economy analytics.
Live-write warning: The Query surface also offers
create_accountsandcreate_transfers. These are real writes to the selected database, not a preview or rollbackable sandbox. Use only the separate non-production database, and review IDs, ledger, codes, flags, and amounts before selecting Run.
CLI walkthrough
The following flow uses the latest published @parix/cli package. The CLI is for operators and developers: it signs in through browser OAuth and acts in the active organization. A production game server uses an API key through the gateway instead of copying or automating the CLI session.
Install the exact CLI version and sign in to the environment that owns the non-production database:
npm install --global @parix/cli@latest
parix --version
parix auth login
parix auth status
parix database list --json
export PARIX_DATABASE_ID="db_replace_with_uuid"parix --version should report the installed package version. The CLI defaults to https://parix.io; use --base-url <other-origin> only when intentionally targeting another deployment. The database ID is positional in every parix tb command. Do not substitute a display name.
Create gaming-accounts.json. The request body is a bare array.
[
{ "id": "810000000000000001", "ledger": 8001, "code": 300, "flags": 8 },
{ "id": "810000000000000002", "ledger": 8001, "code": 200, "flags": 10 },
{ "id": "810000000000000003", "ledger": 8001, "code": 100, "flags": 10 },
{ "id": "810000000000000004", "ledger": 8001, "code": 100, "flags": 10 },
{ "id": "810000000000000005", "ledger": 8001, "code": 400, "flags": 8 }
]Create the accounts, fund the reward pool, and grant Alice 1500 Gold:
parix tb create-accounts "$PARIX_DATABASE_ID" --file ./gaming-accounts.json --json
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 820000000000000001 \
--from 810000000000000001 \
--to 810000000000000002 \
--amount 5000 \
--ledger 8001 \
--code 10 \
--json
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 820000000000000002 \
--from 810000000000000002 \
--to 810000000000000003 \
--amount 1500 \
--ledger 8001 \
--code 11 \
--jsonRecord a 200 Gold purchase and a 50 Gold burn with distinct stable event IDs. Leave user_data_* at zero unless you will query by an external game-event or player correlation; the transfer id remains the idempotency key. Do not put the ledger number in user_data_32—ledger is already a first-class field.
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 820000000000000003 \
--from 810000000000000003 \
--to 810000000000000001 \
--amount 200 \
--ledger 8001 \
--code 20 \
--json
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 820000000000000004 \
--from 810000000000000003 \
--to 810000000000000005 \
--amount 50 \
--ledger 8001 \
--code 21 \
--jsonCreate gaming-trade.json for a trade in which Alice pays Bob 100 Gold and the treasury receives a 5 Gold fee. The first leg is linked; the final leg is not.
[
{
"id": "820000000000000005",
"debit_account_id": "810000000000000003",
"credit_account_id": "810000000000000004",
"amount": "100",
"ledger": 8001,
"code": 30,
"flags": 1
},
{
"id": "820000000000000006",
"debit_account_id": "810000000000000003",
"credit_account_id": "810000000000000001",
"amount": "5",
"ledger": 8001,
"code": 31,
"flags": 0
}
]Submit the trade, look up both legs, and query Gold accounts:
parix tb create-transfers "$PARIX_DATABASE_ID" --file ./gaming-trade.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" --ids 820000000000000005,820000000000000006 --json
parix tb query-accounts "$PARIX_DATABASE_ID" --ledger 8001 --limit 20 --jsonA successful create response has persisted: true and an empty responsePayload ([]). Empty means every item succeeded. HTTP 200 with persisted: false is not a committed write. A conflict carries indexed numeric results; the public HTTP route reports them as HTTP 409 tbResults, and the Node adapter returns them as a non-empty result array. An unlinked batch can contain both committed and rejected items, while a correctly linked chain commits or rejects as a unit. Public conflict details are currently capped at the first 10 results, so reconcile every submitted stable ID; an unlisted item is not proof of success or failure.
These numeric IDs are stable tutorial values. In a real game, allocate each ID once, persist it with the immutable game event, and reuse the same ID and payload for lookup or retry. Never generate a replacement ID after a timeout.
Node.js implementation
@parix/tigerbeetle-node is the published Node.js client for TigerBeetle-shaped operations on the Parix HTTPS gateway. It does not open a native TigerBeetle connection. Pin a package version approved for your environment, configure { baseUrl, apiKey, databaseId } with a database-scoped API key, keep the client behind an application boundary, and validate behavior before production traffic.
The adapter configuration is { baseUrl, apiKey, databaseId }. It serializes JavaScript bigint fields as decimal strings for the strict JSON API and restores bigint response fields. It does not expose the raw gateway envelope field persisted, so durable workflows require exact post-write lookup before advancing. The example uses full account and transfer objects, checks every result array, keeps stable IDs outside retry logic, resolves ambiguous writes by lookup, and destroys the HTTP client in finally.
import type {
Account,
CreateAccountResult,
CreateTransferResult,
Transfer,
} from '@parix/tigerbeetle-node';
import { AccountFlags, CreateTransferError, TransferFlags, createClient } from '@parix/tigerbeetle-node';
const client = createClient({
baseUrl: process.env.PARIX_BASE_URL ?? 'https://parix.io',
apiKey: mustGetEnv('PARIX_API_KEY'),
databaseId: mustGetEnv('PARIX_DATABASE_ID'),
});
const ledger = 8001;
const accountId = {
treasury: 810000000000000001n,
rewardPool: 810000000000000002n,
alice: 810000000000000003n,
bob: 810000000000000004n,
sink: 810000000000000005n,
} as const;
// Persist these with their game events before submission. Never call an ID generator in retry code.
const transferId = {
fundRewardPool: 820000000000000001n,
grantAlice: 820000000000000002n,
purchaseAlice: 820000000000000003n,
burnAlice: 820000000000000004n,
tradePrincipal: 820000000000000005n,
tradeFee: 820000000000000006n,
compensatePrincipal: 820000000000000007n,
compensateFee: 820000000000000008n,
} as const;
function account(id: bigint, code: number, flags: number): Account {
return {
id,
debits_pending: 0n,
debits_posted: 0n,
credits_pending: 0n,
credits_posted: 0n,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
reserved: 0,
ledger,
code,
flags,
timestamp: 0n,
};
}
function transfer(input: {
id: bigint;
debitAccountId: bigint;
creditAccountId: bigint;
amount: bigint;
code: number;
flags?: number;
}): Transfer {
return {
id: input.id,
debit_account_id: input.debitAccountId,
credit_account_id: input.creditAccountId,
amount: input.amount,
pending_id: 0n,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
ledger,
code: input.code,
flags: input.flags ?? TransferFlags.none,
timestamp: 0n,
};
}
function assertEmptyResults(operation: string, results: CreateAccountResult[] | CreateTransferResult[]): void {
if (results.length === 0) return;
const capacityDecline = results.some((item) => item.result === CreateTransferError.exceeds_credits);
throw new Error(
`${operation} rejected${capacityDecline ? ' (capacity)' : ''}: ${JSON.stringify(results)}`,
);
}
function getHttpStatus(cause: unknown): number | undefined {
if (!cause || typeof cause !== 'object' || !('status' in cause)) return undefined;
const status = (cause as { status?: unknown }).status;
return typeof status === 'number' ? status : undefined;
}
function wasDefinitelyRejected(cause: unknown): boolean {
const status = getHttpStatus(cause);
return status !== undefined && [400, 401, 402, 403, 404, 409, 429].includes(status);
}
function hasSameImmutableAccountFields(actual: Account, intended: Account): boolean {
return (
actual.id === intended.id &&
actual.user_data_128 === intended.user_data_128 &&
actual.user_data_64 === intended.user_data_64 &&
actual.user_data_32 === intended.user_data_32 &&
actual.reserved === intended.reserved &&
actual.ledger === intended.ledger &&
actual.code === intended.code &&
actual.flags === intended.flags
);
}
function hasSameImmutableFields(actual: Transfer, intended: Transfer): boolean {
return (
actual.id === intended.id &&
actual.debit_account_id === intended.debit_account_id &&
actual.credit_account_id === intended.credit_account_id &&
actual.amount === intended.amount &&
actual.pending_id === intended.pending_id &&
actual.user_data_128 === intended.user_data_128 &&
actual.user_data_64 === intended.user_data_64 &&
actual.user_data_32 === intended.user_data_32 &&
actual.timeout === intended.timeout &&
actual.ledger === intended.ledger &&
actual.code === intended.code &&
actual.flags === intended.flags
);
}
async function accountsExistExactly(batch: Account[]): Promise<boolean> {
const found = await client.lookupAccounts(batch.map((item) => item.id));
if (found.length === 0) return false;
const intendedById = new Map(batch.map((item) => [item.id, item]));
if (
found.length !== batch.length ||
!found.every((item) => {
const intended = intendedById.get(item.id);
return intended !== undefined && hasSameImmutableAccountFields(item, intended);
})
) {
throw new Error('Account IDs are only partially present or exist with different immutable fields');
}
return true;
}
async function transfersExistExactly(label: string, batch: Transfer[]): Promise<boolean> {
const found = await client.lookupTransfers(batch.map((item) => item.id));
if (found.length === 0) return false;
const intendedById = new Map(batch.map((item) => [item.id, item]));
if (
found.length !== batch.length ||
!found.every((item) => {
const intended = intendedById.get(item.id);
return intended !== undefined && hasSameImmutableFields(item, intended);
})
) {
throw new Error(`${label} IDs are only partially present or exist with different immutable fields`);
}
return true;
}
async function createAccountsOrResolveAmbiguity(batch: Account[]): Promise<void> {
// This lookup makes a new process safe after an earlier process committed but
// stopped before persisting the HTTP response.
if (await accountsExistExactly(batch)) return;
let results: CreateAccountResult[] | CreateTransferResult[];
try {
results = await client.createAccounts(batch);
} catch (cause) {
if (wasDefinitelyRejected(cause)) throw cause;
if (await accountsExistExactly(batch)) return;
throw new Error('Accounts were not found; retry only with the same IDs and payload', { cause });
}
if (await accountsExistExactly(batch)) return;
if (results.length === 0) {
throw new Error('Account write was not confirmed by lookup; do not advance or change the payload');
}
assertEmptyResults('createAccounts', results);
}
async function createTransfersOrResolveAmbiguity(label: string, batch: Transfer[]): Promise<void> {
// Stable-ID lookup is also the first step after a worker or process restart.
if (await transfersExistExactly(label, batch)) return;
let results: CreateAccountResult[] | CreateTransferResult[];
try {
results = await client.createTransfers(batch);
} catch (cause) {
if (wasDefinitelyRejected(cause)) throw cause;
if (await transfersExistExactly(label, batch)) return;
throw new Error(`${label} was not found; retry only with the same IDs and payload`, { cause });
}
if (await transfersExistExactly(label, batch)) return;
if (results.length === 0) {
throw new Error(`${label} was not confirmed by lookup; do not advance or change the payload`);
}
assertEmptyResults(label, results);
}
async function settleTrade(commitInventory: () => Promise<'committed' | 'rejected' | 'unknown'>): Promise<void> {
const trade = [
transfer({
id: transferId.tradePrincipal,
debitAccountId: accountId.alice,
creditAccountId: accountId.bob,
amount: 100n,
code: 30,
flags: TransferFlags.linked,
}),
transfer({
id: transferId.tradeFee,
debitAccountId: accountId.alice,
creditAccountId: accountId.treasury,
amount: 5n,
code: 31,
flags: TransferFlags.none,
}),
];
await createTransfersOrResolveAmbiguity('trade', trade);
const inventoryOutcome = await commitInventory();
if (inventoryOutcome === 'committed') return;
if (inventoryOutcome === 'unknown') {
throw new Error('Inventory outcome is ambiguous; look up the stable trade ID before compensation');
}
// Compensation is another linked ledger event; it never erases the original trade.
await createTransfersOrResolveAmbiguity('trade compensation', [
transfer({
id: transferId.compensatePrincipal,
debitAccountId: accountId.bob,
creditAccountId: accountId.alice,
amount: 100n,
code: 40,
flags: TransferFlags.linked,
}),
transfer({
id: transferId.compensateFee,
debitAccountId: accountId.treasury,
creditAccountId: accountId.alice,
amount: 5n,
code: 41,
flags: TransferFlags.none,
}),
]);
}
async function main(): Promise<void> {
const constrainedHistory = AccountFlags.history | AccountFlags.debits_must_not_exceed_credits;
await createAccountsOrResolveAmbiguity([
account(accountId.treasury, 300, AccountFlags.history),
account(accountId.rewardPool, 200, constrainedHistory),
account(accountId.alice, 100, constrainedHistory),
account(accountId.bob, 100, constrainedHistory),
account(accountId.sink, 400, AccountFlags.history),
]);
await createTransfersOrResolveAmbiguity('fund reward pool', [
transfer({
id: transferId.fundRewardPool,
debitAccountId: accountId.treasury,
creditAccountId: accountId.rewardPool,
amount: 5000n,
code: 10,
}),
]);
await createTransfersOrResolveAmbiguity('grant Alice', [
transfer({
id: transferId.grantAlice,
debitAccountId: accountId.rewardPool,
creditAccountId: accountId.alice,
amount: 1500n,
code: 11,
}),
]);
await createTransfersOrResolveAmbiguity('purchase', [
transfer({
id: transferId.purchaseAlice,
debitAccountId: accountId.alice,
creditAccountId: accountId.treasury,
amount: 200n,
code: 20,
}),
]);
await createTransfersOrResolveAmbiguity('burn', [
transfer({
id: transferId.burnAlice,
debitAccountId: accountId.alice,
creditAccountId: accountId.sink,
amount: 50n,
code: 21,
}),
]);
// The real callback must be an idempotent application-DB transaction keyed by the trade ID.
await settleTrade(async () => 'committed');
}
function mustGetEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
try {
await main();
} finally {
client.destroy();
}Code 40 in this tutorial debits Bob's seller wallet because the trade already credited that wallet. The compensation assumes Bob's credited amount is still recoverable. A production trade system that cannot guarantee that must route proceeds through a constrained trade-hold account (a separate account role, not transfer code 40 alone) and release them only after inventory commits, or otherwise reserve the credited value. Never weaken player balance constraints to force compensation through.
The inventory callback must distinguish committed, rejected, and ambiguous transport outcomes. On an ambiguous outcome, query the inventory database by stable trade ID before returning rejected; otherwise an automatic compensation can reverse currency for an inventory transfer that actually committed.
Failure and retry handling
| Signal | Meaning | Action |
|---|---|---|
Empty create result [] | Adapter reported no item conflicts | Still look up every stable ID and exact immutable fields before advancing. The adapter does not expose gateway persisted. |
| Nonempty create result | Indexed items were rejected; unlinked items may still have committed | Reconcile every submitted ID. The adapter unwraps HTTP 409 + tbResults into this array; only the first 10 conflict details may be returned. |
| Thrown HTTP error without item results | Auth, schema, plan, bare 409, or transport failure | Classify by status. For ambiguous outcomes (5xx, timeout, disconnect), look up before retrying identical IDs. |
| Insufficient balance result | Player or reward pool constraint rejected the debit | Return the domain failure; do not retry unless a new, authorized credit changes the state |
HTTP 400 | Strict payload, flag, ledger, or field validation failed | Fix the request; do not retry unchanged |
HTTP 401 or 403 | Credential, scope, organization, or database is wrong | Stop and correct configuration; never fall back to a client-side credential |
HTTP 402 | Developer billing state blocks the operation | Restore the subscription/billing state; do not retry unchanged |
HTTP 404 | Database, profile, route, or visible resource is absent | Verify environment and database UUID; do not retry blindly |
HTTP 429 | Rate/admission saturation or a durable plan quota | Back off with jitter only for transient rate/admission limits; for quota exhaustion, wait for reset or change the plan/workload |
Timeout, disconnect, HTTP 500 or 503 | Ledger outcome can be ambiguous | Lookup all stable IDs; accept exact matches, investigate a partial linked observation, retry same IDs only when absent |
| Inventory transaction definitively rejected | Currency trade committed but item handoff did not | Submit the preallocated linked compensation IDs and retain both original and compensation history |
| Inventory transaction outcome ambiguous | Application database may have committed | Lookup the trade in the inventory database before compensation |
| Compensation rejected | Receiver spent funds or another invariant blocked it | Stop automation, freeze or hold affected trade state, and escalate reconciliation |
The Node adapter returns TigerBeetle item conflicts as a non-empty result array rather than throwing them as a transport error. Keep the result-array check outside the transport catch, as shown, so a deterministic rejection is not mistaken for an ambiguous commit.
Test scenarios
Use an isolated non-production Parix database. Never point automated tests or economy simulations at the production database, even if they use a different ledger number.
| Scenario | Setup/action | Expected result |
|---|---|---|
| Reward grant | Fund pool, grant player 1500 | Empty results; pool decreases and player spendable balance increases |
| Duplicate reward event | Deliver the same stable grant ID and payload twice | Only one grant exists; retry is recognized/reconciled, not minted twice |
| Reward budget exhausted | Grant more than the constrained pool balance | Transfer rejected; no player credit |
| Purchase with sufficient balance | Debit player 200 to treasury | Empty result; item workflow proceeds idempotently |
| Concurrent overspend | Submit purchases whose combined value exceeds the player balance | Only allowable debits commit; player never goes below zero |
| Burn | Debit player 50 to sink | Player decreases, sink increases, source event remains queryable |
| Atomic trade | Submit principal plus fee with linked only on the first leg | Both legs commit or neither commits |
| Trade fee leg invalid | Use an invalid treasury account on the final leg | Principal leg also fails |
| Open linked chain | Put linked on the final trade leg | Chain rejected with no trade movement |
| Duplicate trade delivery | Resubmit the exact two IDs and payload | No second principal or fee |
| Inventory definitive failure | Commit ledger trade, then have inventory return rejected | Linked compensation restores principal and fee; original history remains |
| Inventory ambiguous response | Commit inventory but drop its response | Inventory lookup finds the trade; no erroneous compensation |
| Receiver spends before compensation | Spend Bob's trade credit, then reject inventory | Compensation constraint can fail; incident/hold procedure activates |
| Ambiguous Parix write | Drop the client response after sending a grant or trade | Stable-ID lookup occurs before a same-ID retry |
| Shared query without ledger | Query Developer without ledger | Request rejected; ledger 8001 query succeeds |
| Environment isolation | Run test IDs and credentials against test configuration | Only the test database changes; production lookup remains empty |
| Effective batch limit | Submit at plan limit and one item above it | At-limit request is handled; above-limit request is rejected without generating new IDs |
Production operations
- Use different Parix databases for test and production, with separate API keys, environment variables, stable-ID namespaces, alerts, reconciliation jobs, and access roles.
- Run production on Production HA, Production 6, or a contract-defined Enterprise plan. Developer and Dedicated Single Node remain non-production.
- Keep API keys in server-side secret storage, scope them to one database, rotate them, and never ship them in a game binary, browser bundle, launcher, or mod-accessible configuration.
- Gate treasury debits, reward-pool funding, manual grants, and adjustments behind least-privilege services and reviewed operator workflows.
- Reconcile grants to source gameplay events, purchases to entitlements, burns to reasons, trades to inventory records, and compensation to the original trade.
- Monitor source and sink velocity, reward-pool runway, non-empty result arrays, insufficient-balance rates, ambiguous outcomes, compensation failures, hot accounts, request latency, and plan denials.
- Use an outbox/inbox or durable workflow for reward queues and inventory sagas. Deduplicate at the game-event boundary as well as with stable ledger IDs.
- Consider per-event or per-shard source accounts when one treasury or sink becomes a hot operational account, while keeping roll-up and reconciliation rules explicit.
- Batch below both the 8,190 schema maximum and the active plan limit. Keep an entire linked trade chain in one request.
- Practice credential rotation, database recovery, reconciliation replay, and degraded-provider procedures before a launch or live event.
