Skip to main content
PARIXDocs

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

ComponentOwnsDoes not own
Game serverAuthentication, anti-cheat, reward eligibility, prices, trade rules, stable event IDs, sagasAuthoritative ledger mutation after Parix accepts a transfer
ParixCurrency accounts, transfer history, balance constraints, linked-batch atomicityInventory metadata, progression, matchmaking, or client session state
Inventory databaseItem instances, ownership, equipment, quantities, trade state, idempotent inventory commitsFungible-currency balance
Economy operationsMint budgets, manual-grant approval, source/sink policy, reconciliation, incident responseRewriting committed ledger history
Analytics/read modelsWallet display, transaction history, source/sink dashboards, economy forecastsAuthorization 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

LedgerUnitPurpose
8001GoldRewards, purchases, burns, player trades, and Gold-denominated fees
8002GemsSeparate, 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.

RoleExample IDCodeFlagsBalance meaning and control
Treasury/source8100000000000000013008Privileged source and purchase revenue; only reviewed server workflows may debit
Reward pool81000000000000000220010Pre-funded reward budget; cannot grant more than its posted credits
Player Alice wallet81000000000000000310010Alice's spendable Gold
Player Bob wallet81000000000000000410010Bob's spendable Gold
Currency sink8100000000000000054008Gold 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

CodeEventDebitCredit
10Fund reward poolTreasury/sourceReward pool
11Grant rewardReward poolPlayer wallet
20PurchasePlayer walletTreasury
21BurnPlayer walletCurrency sink
30Trade principalBuyer walletSeller wallet
31Trade platform feeBuyer walletTreasury
40Trade principal refundSeller walletBuyer wallet
41Trade fee refundTreasuryBuyer wallet
90Reviewed adjustmentPolicy-definedPolicy-defined

Do not overload one code for rewards, purchases, and administrative corrections. Codes are part of the operational audit vocabulary.

Invariants

InvariantEnforcement
A player cannot spend Gold they do not haveApply debits_must_not_exceed_credits to every player wallet
A reward campaign cannot exceed its budgetPre-fund a constrained reward-pool account and grant only from that account
A reward or purchase is applied oncePersist a stable transfer ID derived from the immutable game event before submission
Trade principal and fee settle togetherPut both transfers in one batch; set linked on the first/non-final leg and omit it from the final leg
Currency is not mixedUse accounts and transfers from exactly one ledger for each currency-denominated leg
Sources and sinks are explicitMint only through authorized treasury funding codes; burn only into defined sink accounts
Inventory is not inferred from currencyTreat the inventory database as authoritative and compensate ledger settlement when an inventory saga definitively fails
Test activity cannot contaminate productionUse separate Parix databases, credentials, IDs, reconciliation, and telemetry for test and production

Economy flows

FlowRequired sequence
GrantValidate the reward event, load its persisted transfer ID, debit the funded reward pool, credit the player, inspect result array
PurchaseLock or compare-and-set the purchase intent, debit the player, credit treasury, then commit the item entitlement idempotently
BurnValidate the burn reason, debit the player, credit the named sink, retain the source event reference
TradePersist 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:

PlanWorkload posture
DeveloperLearning, SDK tests, and prototypes only; shared and quota-limited
Dedicated Single NodeIsolated development, staging, simulations, or non-HA work; not production
Production HA/Production 6Production game economies requiring a supported production topology
EnterpriseContract-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

  1. Select the non-production organization and database. Confirm Ready, the plan, database UUID, and environment-specific name.
  2. Review account, transfer, open-pending-transfer, event-per-request, read, and write limits shown for the database.
  3. Select Connect, create a Specific database API key for the server workload, and store the one-time secret outside source control.
  4. 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.
  5. Review Metrics after test traffic to confirm accepted writes, failures/denials, latency, and quota consumption available for the plan.

Developer shared-project metrics showing quota cards and request telemetry

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_accounts and create_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 \
  --json

Record 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_32ledger 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 \
  --json

Create 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 --json

A 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

SignalMeaningAction
Empty create result []Adapter reported no item conflictsStill look up every stable ID and exact immutable fields before advancing. The adapter does not expose gateway persisted.
Nonempty create resultIndexed items were rejected; unlinked items may still have committedReconcile 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 resultsAuth, schema, plan, bare 409, or transport failureClassify by status. For ambiguous outcomes (5xx, timeout, disconnect), look up before retrying identical IDs.
Insufficient balance resultPlayer or reward pool constraint rejected the debitReturn the domain failure; do not retry unless a new, authorized credit changes the state
HTTP 400Strict payload, flag, ledger, or field validation failedFix the request; do not retry unchanged
HTTP 401 or 403Credential, scope, organization, or database is wrongStop and correct configuration; never fall back to a client-side credential
HTTP 402Developer billing state blocks the operationRestore the subscription/billing state; do not retry unchanged
HTTP 404Database, profile, route, or visible resource is absentVerify environment and database UUID; do not retry blindly
HTTP 429Rate/admission saturation or a durable plan quotaBack 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 503Ledger outcome can be ambiguousLookup all stable IDs; accept exact matches, investigate a partial linked observation, retry same IDs only when absent
Inventory transaction definitively rejectedCurrency trade committed but item handoff did notSubmit the preallocated linked compensation IDs and retain both original and compensation history
Inventory transaction outcome ambiguousApplication database may have committedLookup the trade in the inventory database before compensation
Compensation rejectedReceiver spent funds or another invariant blocked itStop 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.

ScenarioSetup/actionExpected result
Reward grantFund pool, grant player 1500Empty results; pool decreases and player spendable balance increases
Duplicate reward eventDeliver the same stable grant ID and payload twiceOnly one grant exists; retry is recognized/reconciled, not minted twice
Reward budget exhaustedGrant more than the constrained pool balanceTransfer rejected; no player credit
Purchase with sufficient balanceDebit player 200 to treasuryEmpty result; item workflow proceeds idempotently
Concurrent overspendSubmit purchases whose combined value exceeds the player balanceOnly allowable debits commit; player never goes below zero
BurnDebit player 50 to sinkPlayer decreases, sink increases, source event remains queryable
Atomic tradeSubmit principal plus fee with linked only on the first legBoth legs commit or neither commits
Trade fee leg invalidUse an invalid treasury account on the final legPrincipal leg also fails
Open linked chainPut linked on the final trade legChain rejected with no trade movement
Duplicate trade deliveryResubmit the exact two IDs and payloadNo second principal or fee
Inventory definitive failureCommit ledger trade, then have inventory return rejectedLinked compensation restores principal and fee; original history remains
Inventory ambiguous responseCommit inventory but drop its responseInventory lookup finds the trade; no erroneous compensation
Receiver spends before compensationSpend Bob's trade credit, then reject inventoryCompensation constraint can fail; incident/hold procedure activates
Ambiguous Parix writeDrop the client response after sending a grant or tradeStable-ID lookup occurs before a same-ID retry
Shared query without ledgerQuery Developer without ledgerRequest rejected; ledger 8001 query succeeds
Environment isolationRun test IDs and credentials against test configurationOnly the test database changes; production lookup remains empty
Effective batch limitSubmit at plan limit and one item above itAt-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.