# Operator back office API and console (M8 and M12)

Read-only endpoints for operator support and finance staff: look up a player, search and page through their
rounds, open one round with its ledger entries, and export a player's rounds as CSV.

## Access

- **API key scope:** `reports`. Issue it with `POST /operator/v1/api-keys`.
- **Off by default:** an owner must turn the module on per operator:
  `operator_feature_flags(operator_id, module='backoffice', enabled=true)`. Until then every call returns `403 BACKOFFICE_DISABLED`.
- **Kill switch:** `safety_switches(scope='module', subject='backoffice')` stops the module for everyone and returns `503 BACKOFFICE_STOPPED`. It does not affect play or money admission.
- **Operator isolation:** an operator only ever sees its own accounts and rounds. Another operator's player or round returns `404 BACKOFFICE_RESOURCE_NOT_FOUND`, exactly like a missing one.
- **Access record:** every successful read appends an operator action event (`backoffice.player.read`, `backoffice.rounds.search`, `backoffice.round.read`, `backoffice.rounds.export`) with the API key, request id and filters. Refused reads write nothing. Report reads add `backoffice.report.read` / `backoffice.report.export` / `backoffice.liability.read`, and reading the activity log adds `backoffice.activity.read`. Events go through `append_backoffice_read_event` (migrations 200–202), which re-checks the API key's operator and accepts only the actions listed in `backoffice_action_shapes` with their exact metadata keys (at most 2 KB).
- Responses carry `cache-control: no-store`.

## Endpoints

### `GET /operator/v1/backoffice/players?externalPlayerId=…[&currency=EUR]`

Returns the player's accounts, one per currency:

```json
{ "externalPlayerId": "p-123", "walletMode": "custodial",
  "accounts": [ { "accountId": "…", "currency": "EUR", "balance": 98700, "createdAt": "2026-10-20T10:00:00.123456Z",
                  "stopped": false, "lastRoundAt": "2026-10-20T11:02:03.000001Z",
                  "responsibleGaming": { "realityCheckIntervalSeconds": 3600, "maxSingleWager": null,
                                         "dailyNetLossLimit": 5000, "setBy": "operator", "updatedAt": "…" } } ] }
```

- `balance` is the RGS-held balance in minor units, and is `null` for external-wallet operators (the operator holds the money).
- `stopped` is true when an account-scope safety switch is active.
- `responsibleGaming` is `null` when no limits are set.

The lookup also returns, across the player's accounts:
- `freeRounds`: the latest 20 free-round grants (`campaign`, `game`, `granted`, `remaining`, `grantedAt`, `endsAt`, `campaignStatus`);
- `tournaments`: the latest 20 entries (`code`, `joinedAt`, `endsAt`, `rank` now in the players' leaderboard order or `null` before a score, `rounds`, `payoutMinor`, `wagerMinor`).

The console's Players page shows both.

### `GET /operator/v1/backoffice/rounds?externalPlayerId=…`

Optional filters:

| Parameter | Meaning |
|---|---|
| `currency` | One currency account only. |
| `from`, `to` | ISO-8601 with offset. The default window is the last 30 days; at most 92 days. |
| `status` | A comma list from `created, debit_pending, open, credit_pending, settled, cancel_pending, cancelled, manual_review`. |
| `gameCode` | One game. |
| `limit` | Page size, 1–200, default 50. |
| `cursor` | The `nextCursor` from the previous page. |

Rounds come newest first, ordered by `(created_at desc, round id desc)`. `nextCursor` is opaque and is `null` on the last page. Paging never skips or repeats a round.

Each round carries:
- `roundId`, `accountId`, `externalRoundId`;
- `gameCode`, `gameVersion`, `status`, `currency`;
- `wager` and `payout` in minor units;
- `settlementMode`, `fundingSource`, `freeRoundGrantId`, `roomId`;
- `createdAt` and `settledAt`, in UTC with microseconds.

Each round also has `wallet`: the status of each call to the operator's wallet by role, for example `{ "debit": "approved", "credit": "declined" }`; empty in custodial mode. The console's round list shows it as a Wallet column ("ok" when every call was approved).

### `GET /operator/v1/backoffice/rounds/{roundId}`

Returns the round, plus `externalPlayerId`, `playAction` and up to 100 `ledger` entries (`type`, `amount`, `currency`, `balanceAfter`, `createdAt`) in posting order.

`walletOperations` lists the round's calls to the operator's wallet (external wallet mode; empty in custodial mode): at most one `debit`, `credit` and `cancel`, oldest first. Each has `provider` (`operator-wallet` for the signed wallet API; `betsymphony` for BetSymphony's wager, shown as `debit`, and result, shown as `credit`), `role`, `kind`, `status` (`pending`, `approved`, `declined`, `unknown`, `manual_review`, `cancelled`), `amount` (minor units; `null` on a cancel), `currency`, `operationId` (the id the RGS sent your wallet), `remoteTransactionId` (your wallet's transaction id), `attempts`, `lastAttemptAt`, `lastError` (cut to 300 characters), `responseSource` (`direct_response`, `recovery_response` or `callback`), `createdAt` and `completedAt`. Support uses it to answer "did the bet or the win reach the player's balance": a round in `manual_review` with a `declined` credit means your wallet refused the win.

### `GET /operator/v1/backoffice/rounds.csv?externalPlayerId=…`

Takes the same filters as the search, without `limit` or `cursor`.

- It returns the whole window as CSV with the header row `round_id,external_round_id,created_at,settled_at,game_code,game_version,status,currency,wager,payout,settlement_mode,funding_source,free_round_grant_id,room_id,debit_status,debit_operation_id,debit_wallet_transaction_id,credit_status,credit_operation_id,credit_wallet_transaction_id,cancel_status,cancel_operation_id,cancel_wallet_transaction_id`.
- The last nine columns are the round's calls to your wallet (external wallet mode): each call's status, the operation id the RGS sent you and your wallet's transaction id. Together they match each round against your own wallet records, for an AML (SAR) or dispute file. They are empty in custodial mode, and for a call that was never made.
- The `x-export-rows` header gives the row count. Money is written as exact integers in minor units.
- Text cells that start with `= + - @`, tab or CR get a leading `'` so spreadsheets don't execute them.
- A window with more than 50,000 rounds is refused with `422 BACKOFFICE_EXPORT_TOO_LARGE` rather than silently truncated. Narrow the window and try again.

### `GET /operator/v1/backoffice/reports/daily[?from=YYYY-MM-DD&to=YYYY-MM-DD&currency=EUR&gameCode=…&groupBy=day|game|market]`

Daily GGR/NGR, read only from the projection that the worker maintains (migration 201). It never scans rounds.

- **Days:** UTC calendar days of settlement, inclusive. The default is the 30 days ending today; the maximum is 92 days.
- **`groupBy=day`** (default): one row per day and currency, including `activeAccounts` (distinct accounts that settled a round that day) and `averagePlaySeconds` (the mean play span: each player's first to last settled round that UTC day, in whole seconds; each span is kept to the minute, so it can read up to a minute short; spans start at migration 217's deploy). Both are `null` when filtered by `gameCode`.
- **`groupBy=game`:** one row per game version and currency over the whole window, with `mathVariant` and `theoreticalRtp`. `theoreticalRtp` comes from the operator's synthetic outcome-monitor model for that version (expected payout ÷ stake, six decimals) and is `null` when no such model exists. A PAR-derived model (migration 269) has no single stake and holds an RTP band; it gives `null` here, and `tools/outcome-monitor.ts status` shows its band.
- **`groupBy=market`:** one row per player market and currency over the whole window (`market` null for rounds whose facts carry none; migration 233). The CSV's first column is `market`.
- **Figures:** amounts are integer strings in minor units.
  - `ggr` = `cashWager` − `cashPayout`.
  - `ngr` = `ggr` − `promoPayout` (payouts of promo-funded free rounds).
  - Jackpot wins are part of a round's payout, so they are already inside `cashPayout` / `promoPayout`. Outstanding campaign and jackpot amounts are liabilities, reported separately.
  - `observedRtp` = cash payout ÷ cash wager; `margin` = GGR ÷ cash wager; `hitRate` = cash rounds that paid more than the stake ÷ cash rounds. Six decimals, rounded half away from zero, `null` when nothing was wagered.
- **`projection`:** `projectedThrough` / `latestFact` (round-fact sequences), `pendingFacts` (not yet counted) and `stalledReason`. When a fact breaks the projection's invariants (a sequence gap, an unknown funding source), that operator's projection stops with the reason instead of guessing; other operators continue.
- At most 10,000 rows; narrow with `currency` or `gameCode` beyond that.
- Measured on a 1-CPU test Postgres with a worst-case operator projection (460,000 rows: 92 days × 500 versions × 5 currencies × 2 funding sources): by day over all currencies ~200 ms; by game over all currencies (2,500 rows) ~290 ms; one currency over 30 days ~25 ms. The control-plane statement budget is 1,500 ms.

### `GET /operator/v1/backoffice/reports/cohorts[?from=YYYY-MM-DD&to=YYYY-MM-DD&currency=EUR]`

D1/D7/D30 retention. Needs module `backoffice-cohorts` as well as `backoffice` (`403 BACKOFFICE_COHORTS_DISABLED` otherwise). Default off: until it is on, the projection keeps no per-account first-day rows.

- A cohort is the set of accounts first seen on `cohortDay` (UTC). `returnedD1`/`D7`/`D30` count those active again exactly 1, 7 and 30 days later. Counts use internal account ids only.
- Each rate (six decimals) stays `null` until the projection has moved past the measured day (`measuredThrough`).
- `baseline: true` marks the first cohort day after activation (`cohortsSince`). It also holds players who were active before activation.
- **Known limit:** for about the first 30 days after activation, a player who returns after a gap can be counted as new. There is no history backfill.

### `GET /operator/v1/backoffice/reports/liability[?currency=EUR]`

What the operator owes or has reserved right now, read from the counters that grants and settlement already maintain. It never scans grants or rounds.

- **`totals`** per currency:
  - `campaignReservedMaximum`: worst-case payout still reserved for granted, unplayed free rounds (granted rounds × maximum payout, released as rounds are played).
  - `campaignOwed`: free-round winnings not yet credited.
  - `jackpotPot`, `jackpotReserve`, `jackpotOwed`: current jackpot pots, reserves for the next cycle, and awarded but not yet credited.
  - `total`: the sum of the five.
- **`campaigns.rows`:** active campaigns, plus removed ones that still reserve or owe something. **`jackpots.rows`:** every tier of the operator's pools, including `paid`, `contributed` and `initial`.
- At most 1,000 rows per list (`truncated` says when more exist); totals always cover everything.

### `GET /operator/v1/backoffice/reports/liability.csv` and `/reports/cohorts.csv`

The same queries as the JSON reports, as CSV, recorded as `backoffice.liability.export` / `backoffice.cohorts.export`.
- **Liability:** one long-form table. `section` is `total`, `campaign` or `jackpot`; cells that do not apply to a section stay empty. Campaign and jackpot amounts owed are separate columns. Headers `x-liability-as-of` and `x-liability-truncated`.
- **Cohorts:** one row per cohort day and currency; empty rate cells are not measurable yet. Header `x-cohorts-measured-through`.

### `GET /operator/v1/backoffice/reports/daily.csv`

Same query and columns as CSV (by day, the last columns are `active_accounts` and `average_play_seconds`). Headers `x-projection-pending-facts` and `x-projection-stalled` carry the projection state.

### `POST /operator/v1/backoffice/wallet-verifications` (scope `verifier`)

The operator's self-service wallet verifier (Tequity's Back Office "Wallet Verifier"). Body: `playerId`, `currency`, `amount` (1–1,000, default 1) and `confirm`, which must repeat `playerId`.

- **Default off:** module `wallet-verifier` per operator; safety switch `module`/`wallet-verifier`. The operator must be in external wallet mode.
- **Use a dedicated test player** whose balance covers `amount`. Run one verification at a time per player; concurrent runs on the same player can report false failures.
- **Sequence (net zero):** balance → debit → cancel that debit → credit → restoring debit → a debit of balance + 1, which must be declined. If the wallet approves it, the verifier cancels it immediately and reports the failure.
- Every step goes through the production external-wallet path: signed request, and a response checked for key, timestamp (±30 s), operation id, currency and exact body. Operation ids derive from the request id and step, so one step can never move money twice.
- A step with no verifiable signed answer (`unknown` / `manual_review`) stops the run; RGS recovery owns that operation.
- **Interrupted runs are finished by the worker** (migration 214):
  - A run is recorded before any money moves and stays open until every money step is compensated, declined or never sent.
  - Five minutes after a run starts, the worker's recovery pass (after the wallet's own lookup recovery) sends any missing cancel or restoring debit under the run's deterministic operation id and the run's own key, so it reaches the wallet at most once. Those operations are recorded under request id `wallet-verifier-recovery:<runId>`.
  - A run waits while any step is unresolved.
  - A declined compensation or an unexpected status closes it as needs-review, logged as `wallet.verifier.needs-review`.
  - A run that stopped before writing its record gets one ("interrupted run").
  - The module safety switch stops recovery too.
- **201 response:** `runId`, `playerId`, `currency`, `amount`, `startedAt`, `finishedAt`, `passed` and `checks[]` (`check`, `status` passed/failed/skipped, `operationId`, `expected`, `observed`). Runs are stored append-only. Each operation is recorded as `external_wallet_operation.create` and the run as `backoffice.wallet.verify`, all under the same request id.
- **Activation gate (MASTER-PLAN H8):** on staging and real-money deployments an operator in external wallet mode launches players (`POST /operator/v1/launch`) only after a run with `passed: true` that started after its connector last changed and finished within 90 days; otherwise `409 WALLET_NOT_VERIFIED`. Pointing the connector anywhere, even at the same endpoint, needs a new pass. Custodial operators are not affected.
- **Not covered yet:** the operator's signed lookup endpoint (`GET /wallet/v1/operations/{id}`), which recovery uses.

### `GET /operator/v1/backoffice/wallet-verifications` (scope `verifier`)

The operator's last 20 runs, newest first. The provider's own confirmation step reads these.

### `GET /operator/v1/backoffice/wallet-verifications/attention` (scope `verifier`)

Runs whose money a person may need to check, newest first (at most 50). Each has `runId`, `requestId`, `playerId`, `currency`, `amount`, `startedAt`, `state` and `closedAt`.

- `running`: open, started under five minutes ago.
- `recovering`: still open and older, so the worker is putting the money back.
- `needs-review`: closed because a compensation was declined or a step needs manual review. Listed for 30 days; the record stays in `wallet_verification_attempts`.

The console's Wallet verifier page shows this list as "Needs attention".

## Round replay

`GET /operator/v1/backoffice/rounds/{roundId}/replay` (scope `reports`; console permission `players`).

Re-runs a settled round of one of the operator's players from its revealed seed. It is the same check a player's own replay runs: request hash, RNG transcript, stored result, payout, game state and seed commitment.
- Returns `{roundId, history, verification}`. `verification.status` is `matched`, `mismatch` or `unavailable` (with `reason`), plus the fairness inputs and the recomputed payout.
- A `mismatch` is still `200`: the comparison is the answer.
- Rounds of other operators are `404 ROUND_NOT_FOUND`.
- Each call is one `backoffice.round.replay` action with the status.
- Its queries run in the account's money transaction, as a player's replay does: fine for support use, not for bulk scans.

## LiveOps schedule

`GET /operator/v1/backoffice/liveops?from=YYYY-MM-DD&to=YYYY-MM-DD&currency=EUR` (scope `reports`; console permission `liveops`).

Free-round campaigns, budget campaigns and tournaments whose schedule overlaps the window, plus jackpot pools.
- **Window:** UTC days, inclusive. The default is 30 days back to 60 days ahead, at most 366 days. `400 INVALID_BACKOFFICE_QUERY` otherwise.
- **Items:** up to 500, ordered by start. Each has `kind` (`free-rounds`, `campaign`, `tournament`), `code`, `games`, `currency`, `status`, `startsAt`, `endsAt`, `budget`, `spent`, `reserved` and `owed`. Amounts are integer strings in minor units.
- **`status`:** the stored state where it decides (`removed`, `draft`, `paused`, `ended`, `closing`, `completed`); otherwise the schedule (`scheduled`, `live`, `finished`).
- **`spent`:** paid out for free rounds and tournaments, awarded for budget campaigns. `reserved` (free rounds) and `owed` (free rounds, tournaments) are null where the module does not track them.
- **Jackpot pools:** pool key, game, scope, tier count, and summed pot, contributed and owed.
- **CSV:** `GET /operator/v1/backoffice/liveops.csv` (same query; console `liveops.csv`, Download CSV on LiveOps). Columns: `kind, code, games, currency, status, starts_at, ends_at, budget, spent, reserved, owed`, in minor units. Header `x-liveops-truncated`. Each export is one `backoffice.liveops.export` action (migration 224).
- Each jackpot pool also carries `tierDetails`: per tier the pot now, reserve, owed, paid, contributed and cycle. The console shows it behind a Tiers button.
- Each call is one `backoffice.liveops.read` action.

## Market tax profiles

`GET /operator/v1/backoffice/markets` (scope `reports`; console `markets`, permission `finance`, the Markets page).

- Every version of the operator's market tax profiles, newest first per market. Each has:
  - `market` (ISO 3166-1 alpha-2, optionally a region such as `US-NJ`);
  - `taxBasis` (`ggr`, `ngr` or `turnover`);
  - `rateBps`, `freeRoundExempt` (turnover only: leave the free rounds' notional stakes out), `validFrom` and `validTo` (UTC days; `validTo` is exclusive);
  - `state` (`scheduled`, `current` or `past`).
- Profiles change only through two-person approval: the change action `market.profile` with terms `{ market, taxBasis, rateBps (0–10000), freeRoundExempt, validFrom }`. A profile must start today or later and after the market's current one. Approving it closes the current profile the day the new one starts, so past months keep the rule they were reported under. The RGS ships no rates.
- Each read is one `backoffice.markets.read` action (migration 232).

## Monthly tax report

`GET /operator/v1/backoffice/reports/tax?month=2026-10[&market=DE&currency=EUR]` and `reports/tax.csv` with the same query (scope `reports`; console `reports/tax` and `reports/tax.csv`, permission `finance`, the Tax page).

- One line per market, currency and profile version within the UTC month: a profile that changes mid-month splits that market's month. Each line has:
  - `market` (null when the rounds carry none), `currency`, and `firstDay` and `lastDay` (inclusive);
  - `cashRounds`, `cashWager`, `cashPayout`, `ggr`, `promoRounds`, `promoWager` (the free rounds' notional stakes), `promoPayout`, `ngr`;
  - `profile` (id, basis, rate, exemption and validity, or null), `taxBase`, `taxDue`;
  - `games`, the same sums per game version.
- Bases, in exact minor units:
  - `ggr` = cash stakes − cash payouts;
  - `ngr` = GGR − free-round payouts;
  - `turnover` = cash stakes, plus free-round stakes unless `freeRoundExempt`.
- `taxDue` is base × rate exactly, in minor units with up to four decimals (`63.6`). It is `0` when the base is negative, and null without a profile. Round it as the market's rules say.
- `complete` is true when the month is over and the daily projection has caught up; `projection` says how far it is. The CSV (one line per market, currency and profile version) carries `x-report-complete` and the two projection headers.
- Reads from the daily projection only (migration 233), never from rounds. At most 10,000 game lines; filter by market or currency beyond that. Each read is one `backoffice.tax.read` or `backoffice.tax.export` action with `market`, `month` and `rows`.
- Markets come from the round facts (`market`, written by C1 once it pins the player's market on the round). Until then every round is in the unknown market. See `docs/design/tax-and-jurisdiction-reports.md`.

## A player's data (GDPR art. 15, optional)

`GET /operator/v1/backoffice/players/data?externalPlayerId=…` (scope `reports`; console `players/data`, permission `players`, the "Download player data (GDPR)" button on the player page).

- Only for operators that turned on the `data-protection` module (an `operator_feature_flags` row; off by default, since not every operator is under GDPR). Otherwise `403 DATA_PROTECTION_DISABLED`.
- Returns `format: 'rgs-player-data-v1'` with:
  - the player-page profile (accounts, responsible-gaming limits, up to 1,000 free-round grants and tournament entries);
  - all-time settled play per currency (rounds, first and last round, cash wager, payouts);
  - notes. Every round with its wallet calls stays in `rounds.csv`.
- Each export is one `backoffice.player.export` action with `accounts` and `rounds` (migration 231).

## Erase a player (GDPR art. 17, optional)

`POST /operator/v1/backoffice/players/erase` with `{"accountId": "…"}` (an owner key, scope `*`). Name any of the player's accounts: the player page shows them (`accounts[].accountId`).

- Only for operators with the back office and the `data-protection` module on, else `403 BACKOFFICE_DISABLED` / `403 DATA_PROTECTION_DISABLED`. An account of another operator, or none, is `404`.
- **Two people:** where the operator has `two-person-changes` on, this call answers `409 TWO_PERSON_REQUIRED`, and the erasure is a change: `POST /operator/v1/changes` with `{"action": "player.erase", "terms": {"accountId": "…"}}`, proposed and decided by two enrolled owner keys (`GET /operator/v1/changes?scope=players` lists them). The approver echoes `termsSha256` and types the account id back as `echo.target`, since an erasure cannot be undone. Approval runs the erasure in the decision's transaction, under the approver's key; a refusal leaves the request pending. The change names the account, never the player id.
- **What changes:** the player id on every account the player holds with the operator (every currency) becomes one pseudonym, `erased:` and a random UUID. It is random, not a keyed hash: nothing has to link to the old id, because money records link by account id, and a random value leaves no key that could re-identify the player. The player's sessions lose IP and user agent, and active ones are revoked. Afterwards the old player id finds nothing: the player page and the data export answer `404`, and to the RGS a later launch with it is a new player. New accounts cannot take an `erased:` id (migration 266).
- **What is kept:** every money record as it is, for the operator's legal retention period (P8): balances, ledger entries, rounds, wallet operations with their signed requests (which carry the old player id), interactive and multiplayer funding intents, wallet statements and reconciliation cases, two-person change terms. The append-only audit trail keeps the old id too: account row images in `audit_events` (hash-chained since 126), including the image the erasure's own update writes, and back-office reads by player id in `operator_action_audit_events`. Wallet verifier runs keep the test player they used. Deleting money records after the retention period is not built.
- **Refused** with `409 PLAYER_ERASURE_REFUSED` (the message says which):
  - while money is in flight: a round of the player that is not settled or cancelled (an open round, an owed credit, manual review), or a wallet operation of the player that is pending or unknown. Erase after it settles;
  - while a self-exclusion or time-out runs on any of the player's accounts (`self_exclusion_until`, `time_out_until` or `self_excluded_until` in the future). Migration 235 carries these blocks to every account the player opens later, matched by the player id; an erasure renames that id, so the block would be lost. Erase after it ends;
  - while an account-scope stop (`safety_switches`, scope `account`) holds one of the player's accounts. Erase after it is lifted;
  - for a demo lobby user (`lobby_users.player_id` is the player id). The lobby username is the person's sign-in, the key of their game grants and part of their player id, and its format (`^[a-z0-9][a-z0-9._-]{0,31}$`, migration 062) cannot take an `erased:` pseudonym, so the lobby cannot be pseudonymised without a schema change. Demo lobby players are not erased by this call.
- **Idempotent:** erasing an erased player (by any of their accounts) returns the same erasure with `alreadyErased: true` and writes nothing.
- Returns `{accountId, accounts, pseudonym, sessionsRevoked, changeId, alreadyErased}`. Each erasure is one `backoffice.player.erase` action whose target is the named account, with `accounts`, `changeId` (null for the one owner call) and `sessions` (revoked); it never holds the player id, IP or user agent.

## Void a held round (two people, migration 279)

Five kinds of round wait in `manual_review` with no other exit: reconciliation settles wallet operations, and drawn review settles a captured, complete outcome or voids one never captured. A void closes them:

- `declined-free-round-debit`: an external free round whose zero-amount debit the operator's wallet declined;
- `declined-released-debit`: a reviewed outcome that drawn review released, whose debit the wallet declined;
- `incomplete-outcome`: a one-shot round held because the game returned `complete:false`, before any wallet call;
- `exposure-review` (migrations 284 and 287): a custodial round its exposure decision holds for review (`safety_exposure_decisions.review`; drawn review's approval answers "exposure payment is under review"), persistent or stateless, whose only money is the debit: a cash round, or since 287 a free round funded by a promotion (the void gives the free round back and refunds nothing). An external-wallet round its exposure decision holds is still refused: its approved debit is a wallet operation;
- `invalid-state` (284): a round held after the draw because the game's next persistent state could not be stored (over 16 KiB compact or 32,768 bytes of jsonb text, or not plain JSON). Its drawn-review custody holds `private-state-evidence-v1` instead of a state, and it was held before any wallet call.

It is always a two-person change, with no one-key route: `POST /operator/v1/changes` with `{"action": "round.void", "terms": {"roundId": "…", "reason": "…"}}` (scope `reconciliation`, the scope of drawn review; `GET /operator/v1/changes?scope=reconciliation` lists them). `reason` is 1–200 characters of readable text (no control or direction characters); the change's `subject` shows the game, wager, currency and player. The proposal is refused with `409` (`round void refused: …`) unless the round is one of the five kinds now. A different enrolled person approves it, echoing `termsSha256` and typing the round id back as `echo.target`, since a void cannot be undone. They are made on the operator API: the console neither lists nor decides them (its change routes refuse them: `403 CHANGE_REFUSED`, "round voids are made on the operator API: two enrolled people with reconciliation keys propose and approve them"), and its activity log shows each void as "Round voids".

- **What it does**, in the decision's transaction (`void_held_round`, which runs only under the approved decision `decide_operator_change` writes first, after its two-person checks; called any other way it refuses with `round void refused: it needs an approved round.void change`): the round ends `cancelled`, with no payout and no wallet call. A declined debit moved nothing; a custodial wager the RGS took (`incomplete-outcome`, `exposure-review`, `invalid-state`) is refunded with one `rollback` ledger entry (idempotency key `{roundId}:void-refund`). A consumed free round goes back to its grant (`remaining` + 1), its owed payout leaves the campaign's `unpaidMinor` and its reserve returns to `availableMinor` (none after the campaign was removed), journalled as `void`; an unconsumed one releases its reservation. The round's persistent-state reservations are released, so the player can play the game again, and the player's stored state is left as it was. The waiting-for-person alerts stop counting it. An `exposure-review` void also takes the round's payout, and its net loss (payout minus wager), back out of `safety_daily_exposure` on the decision's day for the operator, game and account scopes, exactly once; the exposure decision and its `safety.exposure.review` outbox row stay as the record, and drawn-review intents prepared for the round can no longer be approved. An `invalid-state` void never applies the held next state.
- **Refused** (`409 round void refused`) for any other round: one whose debit the wallet approved (an approved external debit is never given back without the wallet), one with a credit, a cancel, a ledger payout, a jackpot, an exposure decision other than a custodial cash round's (a free round's or an external round's exposure hold stays held), a completed drawn resolution or a state transition, and one not in `manual_review`. For an operator an adapter serves, proposing or approving answers `409` (`round.void is unavailable for an operator served by an adapter`): its rounds are the adapter's.
- **Audit:** `held_round_voids` keeps the class, reason, refund, the free round's outcome and both keys and people; the round gets a `round.voided` event with the change id; and one `backoffice.round.void` action (target the round; `changeId`, `class`, `reason`, `refundMinor`, `freeRound`, `makerKeyId`) is written under the approver's key.
- Returns `{decision, result: {roundId, status: "cancelled", class, refundMinor, currency, freeRound}}`, `freeRound` being `none`, `returned` or `released`.

## Alerts

`GET /operator/v1/backoffice/alerts[?stuckAfterMinutes=5&largeWinMultiplier=1000]` (scope `reports`; console `alerts`, permission `reports`, the Alerts page and its nav badge).

- What needs a person now, computed on read in one snapshot. Each alert has `code`, `severity` (`critical`, `warning` or `info`), `count` (capped at 1,000), `detail` and `page` (the console page with the detail):
  - `rounds-stuck`: rounds that have waited longer than `stuckAfterMinutes` (0 to 1440, default 5). Critical when one is waiting for a person: in `manual_review`, or pending on a native wallet operation in `manual_review`, which nothing looks up again. Core reads only its own tables: an adapter whose operation is decided for good moves its round to `manual_review`, or the round stays a warning.
    - A pending round has waited since it was created, however often the worker retried its wallet call.
    - A round in manual review has waited since it got there.
    - Before 2026-10-24 the age came from `updated_at`, which every retry refreshes, so a round whose wallet kept failing never showed.
  - `wallet-verifier`: verifier runs that need review (last 30 days) or are still recovering.
  - `projection-stalled` (critical), or `projection-behind` when more than 10,000 settled rounds are not yet in the reports.
  - `large-wins` (info): settled rounds in the last 24 hours that paid at least `largeWinMultiplier` times their wager (100 to 1,000,000, default 1,000). It links to the Large wins page and reads through the same index (migration 228).
  - `changes-waiting` (info): two-person changes waiting for approval.
- Your own paging can poll this endpoint. The platform also exports these alerts as Prometheus gauges (`rgs_operator_alerts{operator, code, severity}`, default thresholds) for every operator with the back office on (at most 200), and Alertmanager pages the RGS on-call with them (`ops/prometheus/alerts.yml`, group `rgs-operators`). An operator without the module gets no such gauge; for every operator, whatever its modules, `rgs_operator_rounds_pending{operator}` counts the rounds whose debit, credit or cancel has not finished for over 5 minutes, and RgsOperatorRoundsPending pages on it (`docs/operations/alert-runbook.md`). SMS and phone are receiver settings there.
- Counts only, no player data. Each read is one `backoffice.alerts.read` action with `alerts` and `stuckAfterMinutes` (migration 230); the large-win threshold is not recorded.

## Large wins across players

`GET /operator/v1/backoffice/wins[?from=…&to=…&minMultiplier=100&currency=EUR]` (scope `reports`; console `wins`, permission `reports`, the Large wins page).

- Settled rounds of any of your players that paid at least `minMultiplier` (100 to 1,000,000, default 100) times their wager, in the window (default the last 30 days, at most 92). Newest first, at most 100, with `more: true` when there are more. Each is a round with `externalPlayerId` and `multiplier`.
- For AML large-win review and game-integrity checks. Reads go through a partial index that holds only settled rounds paying at least 100× (`rounds_large_win_idx`, migration 228), so writing a round costs one predicate check. Each read is one `backoffice.wins.read` action with `currency`, `from`, `minMultiplier`, `rows` and `to`. In the console, roles without the Players permission see player ids masked.

## A player's daily activity and turnover velocity

`GET /operator/v1/backoffice/players/activity?externalPlayerId=…[&currency=EUR&from=…&to=…]` (scope `reports`; console `players/activity`, permission `players`, the "Daily activity" card on the player page).

- `days`: one entry per UTC day and currency with settled rounds in the window (default the last 30 days): `rounds`, `wager` (cash only), `payout` (including free-round wins) and `net` (payout - wager, the player's side), as integer strings in minor units.
- `velocity`: per currency, once the player has at least three other active days, the latest active day's wager against the median of the other days (`times`, to two places; `null` when that median is 0). It is a plain turnover-spike heuristic for responsible-gaming and AML review, not an alert. The console flags ×3 or more.
- Reads go through `rounds(account_id, created_at desc, id desc)`; each read is one `backoffice.player.activity` action with `currency`, `from`, `to` and `rows` (migration 229). Another operator's player gives 404.

## A player's biggest wins

`GET /operator/v1/backoffice/players/wins?externalPlayerId=…[&currency=EUR&from=…&to=…]` (scope `reports`; console `players/wins`, permission `players`, the "Biggest wins" card on the player page).

- The player's settled rounds with a payout, in the window (default the last 30 days, at most 92), largest payout first, at most 10 per currency account. Each is a round with `multiplier`: payout divided by wager to six places, or `null` without a cash wager.
- For support ("did this player hit anything big") and large-win (AML) review. Reads go through `rounds(account_id, created_at desc, id desc)`. Each read is one `backoffice.player.wins` action with `currency`, `from`, `to` and `rows` (migration 226). Another operator's player gives 404.

## Rounds needing attention

**BetSymphony calls (2026-10-28):** a BetSymphony call is sent once and never resent, so `attempts`, `lastAttemptAt` and `responseSource` are `null`; `lastError` is BetSymphony's refusal code or why the answer is unknown (`transient`, `interrupted-dispatch`). The round list's `wallet`, the attention list and both CSVs carry BetSymphony's statuses and transaction ids in the same `debit` and `credit` columns. Tequity's calls are not shown yet.

### Find a round from a reference

`GET /operator/v1/backoffice/rounds/lookup?reference=…` (scope `reports`; console `rounds/lookup`, permission `players`, because a match shows the player). `reference` (1–256 characters) is what a partner or player quotes: the round id (any case), the operation id the RGS sent with a wallet call (signed wallet or BetSymphony), a wallet's own transaction id (the signed wallet's `remoteTransactionId` or BetSymphony's), or the external round id the round was played under (exact, case-sensitive). Answer: `{ reference, matches }`; each match is a round as in the round list (with `wallet` and `externalPlayerId`) plus `matchedOn` (`round-id`, `wallet-operation-id`, `provider-transaction-id` or `external-round-id`), in that order, at most 10 rounds. Each round comes back once: `matchedOn` is the first way it matched and `matchedOnAll` lists every way, in the same order (a wallet that echoes our operation id as its own transaction id matches both `wallet-operation-id` and `provider-transaction-id`). The ids are unique, so each finds one round per wallet; the external round id is unique only per session, so it finds the operator's ten newest rounds under it. Only the caller's operator is searched; an unknown reference gives `matches: []`. Each lookup is one `backoffice.rounds.lookup` action with `reference` and `matches` (the number of rounds, migration 257), also when nothing matched. The console's Players page has a "Find a round by reference" box on this route; clicking a match opens it under its player.

Indexes: the round's key; each wallet's (operator_id, operation_id) and (operator_id, transaction id) unique indexes (the signed wallet's transaction id through `operator_wallet_operations_remote_transaction_uidx`); for the external round id `rounds_operator_external_idx` (migration 262: `(account_operator_id, external_id, created_at desc, id) where account_operator_id is not null`, built concurrently), walked newest first through the caller's own entries only and stopping at ten. Each round carries its account's operator in `account_operator_id`, set on insert by the `round_operator_binding` trigger (after `round_account_binding` binds the account). Rounds stored before 262 keep it null and come from `rounds_external_history_idx` (`(external_id, created_at desc, id) include (account_id) where account_operator_id is null`, a partial index that never grows), walked as 261 walked them. 261's `rounds_external_lookup_idx` no longer served the lookup, and migration 274 drops it (see the write cost below).

Retained history (MASTER-PLAN 10.1 rule 3; test `backoffice-lookup-history`, 2026-10-29, PostgreSQL in `tools/test-isolated.mjs`, the fixture's own database; the lookup rows were measured as `rgs-backoffice-round-lookup-v4`, and the 2026-10-30 run of `rgs-backoffice-round-lookup-v5` gave every lookup row the same visits and buffers): two operators, each with 1,000 and then 10,000 players, one session per player, two rounds per session (the second under an external id every session repeats: `shared` for this operator, `contested` for the other, whose oldest round of this operator's also carries it), a signed wallet debit on the first round and a BetSymphony wager on the second. Every statement the service sends is captured and run again under `EXPLAIN (ANALYZE, BUFFERS)`; cells are row visits / buffers / execution ms. No plan scans rounds, accounts, sessions or either wallet table sequentially (only the one-row `games` and `game_versions` tables of the fixture), and no plan sorts more than the rows it returns.

| Read | Statement | 1,000 per operator | 10,000 per operator |
|---|---|---:|---:|
| lookup round-id | `rgs-backoffice-round-lookup-v4` | 5 / 24 / 0.22 | 5 / 27 / 0.34 |
| lookup round-id | `rgs-backoffice-rounds-wallet-v4` | 1 / 5 / 0.03 | 1 / 5 / 0.05 |
| lookup operation-id | `rgs-backoffice-round-lookup-v4` | 5 / 24 / 0.22 | 5 / 27 / 0.33 |
| lookup operation-id | `rgs-backoffice-rounds-wallet-v4` | 1 / 5 / 0.07 | 1 / 5 / 0.05 |
| lookup wallet-transaction-id | `rgs-backoffice-round-lookup-v4` | 5 / 18 / 0.19 | 5 / 21 / 0.23 |
| lookup wallet-transaction-id | `rgs-backoffice-rounds-wallet-v4` | 1 / 5 / 0.03 | 1 / 5 / 0.05 |
| lookup betsymphony transaction id | `rgs-backoffice-round-lookup-v4` | 5 / 18 / 0.17 | 5 / 21 / 0.22 |
| lookup betsymphony transaction id | `rgs-backoffice-rounds-wallet-v4` | 1 / 5 / 0.03 | 1 / 5 / 0.05 |
| lookup betsymphony operation id | `rgs-backoffice-round-lookup-v4` | 5 / 24 / 0.18 | 5 / 27 / 0.30 |
| lookup betsymphony operation id | `rgs-backoffice-rounds-wallet-v4` | 1 / 5 / 0.03 | 1 / 5 / 0.06 |
| lookup external-round-id | `rgs-backoffice-round-lookup-v4` | 5 / 18 / 0.15 | 5 / 21 / 0.31 |
| lookup external-round-id | `rgs-backoffice-rounds-wallet-v4` | 1 / 5 / 0.04 | 1 / 5 / 0.06 |
| lookup shared external id | `rgs-backoffice-round-lookup-v4` | 32 / 79 / 0.19 | 32 / 75 / 0.36 |
| lookup shared external id | `rgs-backoffice-rounds-wallet-v4` | 10 / 39 / 0.05 | 10 / 41 / 0.09 |
| lookup contested external id | `rgs-backoffice-round-lookup-v4` | 5 / 17 / 0.14 | 5 / 20 / 0.21 |
| lookup contested external id | `rgs-backoffice-rounds-wallet-v4` | 1 / 5 / 0.03 | 1 / 5 / 0.05 |
| lookup no match | `rgs-backoffice-round-lookup-v4` | 1 / 9 / 0.11 | 1 / 12 / 0.17 |
| round detail | `rgs-backoffice-round-wallet-v3` | 1 / 5 / 0.04 | 1 / 5 / 0.09 |
| attention list | `rgs-backoffice-rounds-attention-v4` | 5 / 13 / 0.12 | 5 / 13 / 0.20 |

Every read stays flat, including an external round id that **another operator's** rounds also carry (`contested`: the other operator's 1,000 and then 10,000 rounds). Before migration 262 that read was the one exception: rounds had no operator column, so the walk passed the other operator's entries for the id (index-only, plus one account-key probe each) before it reached the caller's, 2006 / 4012 / 1.13 at 1,000 and 20006 / 31456 / 10.26 at 10,000 (`rgs-backoffice-round-lookup-v3`, same test before the change). Since 262 the walk keys on the operator and visits only the caller's own entries (5 / 17 / 0.14 and 5 / 20 / 0.21 above).

Operators that number rounds per session (1, 2, 3 ...) share ids by the million, so the test also gives a third operator's 10,000 and then 100,000 rounds the id `crowded`, all newer than this operator's one round under it (the worst case for a newest-first walk). Cells are row visits / buffers / execution ms of the lookup statement under `EXPLAIN (ANALYZE, BUFFERS)`:

| Crowded external id | Statement | 10,000 other-operator rounds | 100,000 other-operator rounds |
|---|---|---:|---:|
| before 262 (walk of `rounds_external_lookup_idx`) | `rgs-backoffice-round-lookup-v3` | 10005 / 483 / 3.85 | the read did not finish: it failed with `ControlPlaneBusy` (the back-office read deadline) before a plan could be measured |
| since 262 (walk of `rounds_operator_external_idx`) | `rgs-backoffice-round-lookup-v4` (v5: 5 / 20 / 0.15 and 5 / 21 / 0.15) | 5 / 20 / 0.16 | 5 / 21 / 0.28 |

Rounds stored before 262 have no operator (262 backfills nothing) and are found through `rounds_external_history_idx`: the test stores two such rounds under one id, one of this operator's and one of another's, and the lookup finds this operator's at 6 / 28 / 0.18 with no sequential scan. That walk still passes other operators' pre-262 rounds under the same id, but the set is fixed when 262 runs (empty on a database created at or after it), so it cannot grow. Why no backfill: setting the operator on every stored round is an UPDATE of the hottest table that writes a new version of each row and an entry in every one of its indexes, and has to pass the rounds' update guards; the partial history index reads them where they are, and a database created at or after 262 has no such rounds.

**Generic plans (2026-10-30, `rgs-backoffice-round-lookup-v5`).** node-postgres prepares the lookup by name, and under PostgreSQL's default `plan_cache_mode=auto` a pooled connection may switch to the statement's generic plan after five executions. A generic plan cannot see a parameter limit. v4 passed the ten-match cap as `$4`; v5 writes it as a literal 10, so both plans know each arm returns at most ten rounds. Test `backoffice-lookup-plans` (one player of this operator, sessions of four rounds numbered `1` to `4`, each statement prepared again under `force_custom_plan` and `force_generic_plan` and run under `EXPLAIN (ANALYZE, BUFFERS)`; cells are row visits / buffers / execution ms):

| Lookup | v4 custom | v4 generic | v5 custom | v5 generic |
|---|---:|---:|---:|---:|
| id `3`, 20,000 rounds | 24 / 48 / 0.22 | 20013 / 686 / 2.98 (sequential scan of rounds) | 24 / 48 / 0.24 | 23 / 50 / 0.31 |
| id `3`, 200,000 rounds | 24 / 58 / 0.43 | 24 / 58 / 0.65 | 24 / 49 / 0.18 | 23 / 51 / 0.24 |
| a round id, 200,000 rounds | 5 / 17 / 0.54 | 6 / 16 / 0.68 | 5 / 17 / 0.13 | 5 / 18 / 0.17 |
| an id no round has, 200,000 rounds | 1 / 6 / 0.30 | 2 / 7 / 0.26 | 1 / 6 / 0.10 | 1 / 6 / 0.17 |

(v4 at 200,000 rounds is from a run of the test before it measured 20,000.) The test pins no sequential scan of rounds under either plan, at most 80 row visits / 192 buffers, and the generic plan within 8 visits / 16 buffers of the custom one. `backoffice-lookup-history` prepares every lookup under `force_generic_plan` too: no sequential scan of retained history and no growth across its sizes. A generic plan cannot drop the arms a null uuid rules out, so a non-uuid reference may cost a few probes more (shared external id 41 / 98 against the custom 32 / 79 at 1,000 players, 41 / 94 against 32 / 75 at 10,000); every other lookup row matches its custom plan within one buffer, and the crowded id is 5 / 21 and 5 / 22 at 10,000 and 100,000 other-operator rounds.

Write cost per new round: the `round_operator_binding` row trigger (one `accounts` primary-key probe), 16 bytes for `account_operator_id` and one entry in `rounds_operator_external_idx`. The history index takes no entries for new rounds. Until migration 274, each new round (and each non-HOT update of one) also wrote an entry into 261's `rounds_external_lookup_idx`, which no lookup plan read after 262; 274 drops it. Every other read of `rounds.external_id` has its own index: rounds' unique `(session_id, external_id)` when the session is known, and 066's `rounds_external_reference_idx` (`(external_id, session_id)`) for dispute evidence's ownership probe, which the planner moved onto 261's index while it existed (every gate from 2026-10-28 to 274) and which reads 066's again (test `dispute-evidence-retained-history`). 274 is a plain `DROP INDEX` in a short contract step, not a concurrent drop, because the migration runner drops concurrently only an index the same migration built: it takes ACCESS EXCLUSIVE on `rounds`, waits for it at most 250 ms per attempt (three attempts, then the migration fails and changes nothing) and holds it only for the catalog change (test `backoffice-lookup-upgrade`).

Measured cost (2026-10-30): three alternating runs of the base without 262 (`b44d089`) and of this branch, base first, each in its own `tools/test-isolated.mjs` database (the same compose Postgres, 1 CPU / 768 MB). Each run makes 200 warm-up and then 2,000 timed one-shot plays through the core calls `POST /v1/play` makes (recover, authorize, play; no HTTP, so no rate limit), then 500 warm-up and 20,000 timed real `rgs-round-insert-v2` inserts, each in its own committed transaction. Cells are median / p90 / p99 ms:

| Run | Side | Play | Round insert |
|---|---|---:|---:|
| 1 | base | 9.10 / 17.88 / 28.02 | 0.682 / 1.484 / 2.515 |
| 2 | 262 | 8.79 / 18.54 / 29.27 | 0.672 / 1.447 / 2.537 |
| 3 | base | 8.15 / 17.70 / 29.02 | 0.687 / 1.490 / 2.488 |
| 4 | 262 | 8.90 / 17.80 / 27.93 | 0.715 / 1.618 / 2.696 |
| 5 | base | 8.22 / 16.99 / 27.27 | 0.897 / 2.106 / 3.148 |
| 6 | 262 | 7.78 / 15.00 / 26.90 | 0.655 / 1.375 / 2.463 |

The difference is inside the noise: runs of the same side differ by up to 15% (plays) and 32% (inserts), and 262's run is faster than the base run before it in two pairs out of three for both paths. The harness cannot resolve a cost as small as 2%. The machine's one-minute load was 195 when run 1 started (decaying from other work) and below 20 from run 3 on. Every one of the 22,700 rounds the 262 runs stored carries its account's operator. The harness was a scratch test file of the wave-6 lane, not kept in the repository.

A round can match a reference more than one way, most often when the wallet echoes our operation id as its own transaction id. It comes back once with `matchedOnAll` listing both ways, the ten-match cap counts it once and the audit's `matches` counts rounds (tests `backoffice-lookup-history`: an echoed id that also names ten external rounds returns the echoed round and the nine newest of them, audited as 10; `backoffice-wallet-calls`: an echoed transaction id through the signed wallet returns one round with both ways, audited as 1).

`GET /operator/v1/backoffice/rounds/attention[?olderThanMinutes=5&status=manual_review]` (scope `reports`; console `rounds/attention`, permission `reports`, a card on Overview shown only when there are such rounds).

- This operator's rounds that have waited longer than `olderThanMinutes` (0 to 1440, default 5): status `created`, `debit_pending`, `credit_pending`, `cancel_pending` or `manual_review`. The age is measured as for `rounds-stuck`: since creation while pending, since entering manual review otherwise. Oldest first. A round in `open` (a player in the middle of a round) is not listed.
- Oldest first, at most 100, with `more: true` when there are more. Each round has the round fields, `externalPlayerId`, `updatedAt` and `wallet`: the status of each wallet call made so far by role, for example `{ "debit": "approved", "credit": "declined" }`.
- `status` narrows the list to one of those five states.
- Reads find the rounds by status through `rounds(status, updated_at)` and check each one's age; each read is one `backoffice.rounds.attention` action with `olderThanMinutes` and `rows` (migration 225). In the console, roles without the Players permission see player ids masked.
- **CSV:** `GET /operator/v1/backoffice/rounds/attention.csv` (same query; console `rounds/attention.csv`, the card's Download CSV) exports the whole list, oldest first, under one cutoff. Columns: `round_id, external_player_id, status, updated_at, created_at, game_code, game_version, currency, wager, payout`, then the nine wallet columns of `rounds.csv`, then the six outlier columns (below). Header `x-export-rows`. Each export is one `backoffice.rounds.attention.export` action with `olderThanMinutes`, `rows` and `status` (migration 227). The console masks player ids for roles without Players.

### Outliers (H1, core row 19)

The same read and its CSV also list accounts whose play is outside two bounds. It is a review aid only: nothing is voided, stopped, capped or blocked. Each operator passes its own thresholds as query parameters on both; the response names the ones it used (`outlierThresholds`). The defaults (`OUTLIER_DEFAULTS`) are what the gauge uses.

- **Window:** the rounds the operator's accounts settled in the last `outlierWindowMinutes` (1 to 1,440, default 60), per account and game version, through `rounds_operator_settled_idx` (migration 267).
- **Velocity:** an account is listed when `rounds >= velocityRounds` (1 to 1,000,000, default 3,600: one round a second for the whole hour), counting all its settled rounds in the window on every version.
- **Win rate:** per account and game version, with `n` cash rounds, cash wager `W` and payout `P` on them in the window, and the version's RTP `RP / RW`, an account is listed when `n >= winRateMinimumRounds` (1 to 1,000,000, default 500), `W > 0` and `P / W > (winRatePercentOfRtp / 100) * RP / RW` (`winRatePercentOfRtp` 101 to 100,000, default 200). The comparison is exact, in integers: `P * 100 * RW > winRatePercentOfRtp * RP * W`.
- **The version's RTP:** when the version pins a math library whose PAR sheet is stored (migration 268), `RP / RW` is that library's exact RTP, the highest of its modes (`math_library_par_moments`, migration 269; the drift monitor reads the same sheet, [rtp-drift-monitor.md](../operations/rtp-drift-monitor.md)). Otherwise `RW` and `RP` are the version's cash wager and payout to this operator's players in the report projection (`backoffice_daily_totals`, the account's currency) over the last 30 UTC days, with `RP` capped at `RW` (no version may return more than it charges), and `1/1` without projected play: the RTP the operator's players have been paid. A capped RTP keeps an account that is most of a version's play listable: at the default its bound is then 200%.
- **Each outlier:** `signal` (`velocity` or `win-rate`), `reason` (its numbers in words), `externalPlayerId`, `currency`, `gameCode` and `gameVersion` (win rate only), `rounds`, `cashRounds`, `wager` and `payout` (cash, minor units, in the window; win rate: that version only), `rtp` (`{ payout, wager }`, win rate only), and floored basis points for reading: `returnBps` (P / W), `rtpBps` and `boundBps`. At most 100, velocity first and then most rounds, with `moreOutliers: true` when there are more. Example: at a 96.00% RTP and the default 200%, the bound is 192.00%, so 500 rounds of 100 paying 97,500 (195.00%) are listed and 95,000 (190.00%) are not.
- **CSV:** one line per outlier after the rounds, with `external_player_id, game_code, game_version, currency, wager, payout` filled, and six columns at the end of every line, empty on round lines: `outlier_signal, outlier_reason, outlier_rounds, outlier_cash_rounds, outlier_return_bps, outlier_bound_bps`. At most 1,000 outliers; more refuses the export. The export's `rows` counts both kinds of line.
- **Metrics:** `rgs_operator_outliers{operator,signal}` counts each operator's outliers under the defaults (capped at 1,000) for operators with the back office on; it has no series when there are none. `RgsOperatorOutliers` warns after 15 minutes and never pages critical (runbook). `rgs_operator_outliers_available` is 0 when the count failed or ran out of its budget; the operator alerts stand either way.
- **Cost:** the read walks only the window's entries of `rounds_operator_settled_idx` for the operators it asks about, and each win-rate candidate adds one walk of the operator's last 30 projection days. Test `backoffice-outliers`: with 10,000 players' rounds settled three days ago and one player's 4,000 rounds two hours ago, the list and the gauge visit exactly the window's rounds, with no sequential scan of rounds.

## Tournament standings

`GET /operator/v1/backoffice/tournaments/{id}/standings` (scope `reports`; console `tournaments/{id}/standings`, permission `liveops`, the Standings button on LiveOps tournament rows).

- The top 50 in the players' leaderboard order: score, then who got there first.
- Each entry has `rank`, `externalPlayerId`, `rounds`, `wagerMinor`, `payoutMinor`, `bestPayoutMinor` and `prizeMinor`, the prize that rank would get now (`null` beyond the prize list). The tournament's `code`, `currency`, `scoring`, `startsAt` and `endsAt` come alongside.
- Player ids are personal data, so each read is one `backoffice.tournament.read` action (migration 222). Another operator's tournament gives 404.
- In the console, roles without the Players permission (finance) see ids masked to their first and last two characters.

## Activity log

`GET /operator/v1/backoffice/activity[?from=YYYY-MM-DD&to=YYYY-MM-DD&kind=changes|all&actor=…&action=…&before=…&limit=1-100]` (scope `keys`: an administrator's read; console permission `activity`, admins only).

- **What:** who did what for the operator, newest first. Every recorded action (API keys, wallet settings, campaigns, grants, console users, approvals, verifier runs, exports and more) is shown under its console username, or under the API key's label with `viaConsole: false`.
- **Window:** UTC days, inclusive; the last 7 days by default, at most 92.
- **`kind=changes`** (default) leaves out page views (back-office reads, searches and replays) and keeps every change and export. `kind=all` shows everything.
- **Filters:** `actor` is exactly a console username or an API key label. `action` is a prefix such as `campaign.`, `api_key.` or `backoffice.change.`. The console offers both as "Who" and "Action". Each read records its filters.
- **Paging:** up to `limit` events (100 by default); `nextCursor` is passed back as `before` for the next page.
- **Each event:** `id`, `occurredAt` (microseconds, UTC), `actor`, `viaConsole`, `action`, `targetType`, `targetId`, `requestId` and `metadata`.
- Each call is one `backoffice.activity.read` action. Indexes: migrations 216 (everything) and 218 (changes only).
- **CSV:** `GET /operator/v1/backoffice/activity.csv[?from&to&kind&actor&action]` (scope `keys`; console `activity.csv` with a Download CSV button). Columns: `occurred_at, actor, via_console, action, target_type, target_id, request_id, metadata` (metadata as JSON), newest first, at most 50,000 rows. Spreadsheet formulas are neutralised. Each export is one `backoffice.activity.export` action (migration 220) and counts against the export limit.

## Operator console (`/operator/console/`)

A single-page console for operator staff, served by the RGS itself (assets compiled into the server, same-origin only under the global CSP). Default off per operator: module `console`, safety switch `module`/`console`.

**Accounts.**
- Bootstrap: an operator API key with scope `keys` calls `POST /operator/v1/console/users` `{username, role}` and gets a one-time setup token, valid 72 hours. The key must be able to grant the new user's key scopes itself (`keys` plus `reports`, plus `campaigns` for support, finance and admin, plus `verifier` for admin, or `*`); otherwise `401`. The key creation is audited under it. The setup link is `/operator/console/setup#<token>`; the token stays in the fragment, out of logs and Referer.
- Setup: the user scans the QR code (or types the shown key, or taps the `otpauth://` link on a phone) into an authenticator (RFC 6238, SHA-1, 6 digits, 30 s), chooses a password of at least 12 characters, and confirms with a code. `setup/info` returns the QR code as `qr: {size, path}`: modules per side and SVG path data drawn on the server, so no QR code runs in the browser.
- Admins create, re-role, disable or reset users in the console, or sign them out everywhere (`users/{id}/sign-out`: every session ends and nothing else changes, for a lost or shared device). Every change ends that user's sessions; admins cannot change their own account.
- `users` also reports `lastSignInAt` and `openSessions` (sessions not ended, expired or idle).
- `users.csv` (admins, the Download CSV button on Users): the list for access reviews. Columns: `username, role, status, approver, created_at, last_sign_in_at, open_sessions, locked_until`. Each export is one `backoffice.console.users.export` action (migration 223).
- **Authenticator secrets at rest:** with `RGS_CONSOLE_TOTP_KEY_FILE` set (32 random bytes in base64), each secret is stored sealed: AES-256-GCM, bound to the user (migration 219). A copy of the database alone cannot produce codes. On staging and real money the key is required: the API refuses to start without it, and `ops/deploy-compose.sh init` writes it. Without the key (dev and demo only), secrets stay plain. With it, older plain secrets are sealed at the user's next sign-in. If the key is missing or different, a sealed user's sign-in returns `503 CONSOLE_UNAVAILABLE` and the server logs why. To rotate, set the new key with the old one as `RGS_CONSOLE_TOTP_PREVIOUS_KEY_FILE`; each user moves to the new key at their next sign-in.

**Sign-in.**
- Operator code, username, password and the current code. Every failure returns the same `401 CONSOLE_LOGIN_FAILED`.
- 5 failures from one client network (IPv4 /24, IPv6 /64) lock that user out from that network for 15 minutes; other networks are not affected. 10 wrong or reused authenticator codes sent with the right password within 15 minutes, from any networks, lock that user out from every network for 15 minutes (wrong passwords never count toward it). Whoever sent them holds the password: reset the user. The action log records that lock as `backoffice.console.login.failed` with `locked: "all-networks"`, and the user list shows it under `lockedUntil`. A reset clears these locks. A code is accepted once (replay-proof), with ±1 step of clock drift.
- Only correct details get `403 CONSOLE_DISABLED` when the console is off; wrong details still get the plain 401.
- Sign-in and setup calls are limited to 20 a minute per client network (`429 RATE_LIMITED` with `retry-after`), on top of the per-address limit for requests without a verified credential (120 a minute, shared across replicas). A signed-in user's requests count against the user's operator key instead (1,200 a minute, under 6,000 a minute per address; w20). A failed sign-in answers no sooner than 250 ms, whether or not the user exists. Exports and replays are limited to 30 a minute per console user and per operator key.

**Sessions.**
- Random 32-byte token in the cookie `rgs_console` (`HttpOnly; Secure; SameSite=Strict; Path=/operator/console`); only its SHA-256 is stored.
- 8 hours absolute, 30 minutes idle.
- The worker removes session rows 30 days after their absolute expiry while any operator has the console on. Sign-ins and sign-outs stay in the action log.
- State-changing calls also need the header `x-console-csrf: 1`.

**Roles.**

| Role | Can |
|---|---|
| viewer | daily report, retention |
| support | + players, rounds, round CSV, LiveOps |
| finance | + report CSVs, liability, LiveOps |
| admin | everything, plus wallet verifier, users and activity |

**Action log.** Each user acts through its own operator API key, labelled `console:<username>`. Its secret is never issued.
- Renewed at login within 30 days of its 90-day expiry; retired on disable; a reset replaces it with a fresh key.
- Revoking it with `DELETE /operator/v1/api-keys/{id}` ends that user's console access at once (sessions and sign-in) until an admin resets the user.
- Logins, failed logins, logouts and user changes are `backoffice.console.*` actions, and every report, export and lookup made in the console is recorded under that user's key.

**Approvers (two-person approval).**
- People are owner-enrolled human principals (migration 113). Only the platform owner registers them, with the database owner's connection:
  - `node --import tsx tools/console-approvers.ts list --operator <code>`
  - `node --import tsx tools/console-approvers.ts enroll --operator <code> --user <username> --environment <id>`
  - `node --import tsx tools/console-approvers.ts revoke --operator <code> --user <username> --reason <text> --environment <id>`
- The running app can only read these tables, so it can never enrol anyone.
- The automatic key renewal carries an enrollment to the new key (migration 211).
- A reset or a disable drops approver status, so a user who sets up again needs the owner to enrol them again. Every enrollment is a new person; `enroll` prints who created and reset the account and when setup was completed, so the owner can confirm with the person first (an admin who resets an account receives its setup link).
- A revocation is permanent for that person; enrolling again needs a reset first.
- `me` and `users` report `approver`.

**Two-person changes (LiveOps).**
- An approver proposes an exact change: `POST changes` `{action, terms, ttlSeconds}`, where `action` is one of:
  - `free-rounds.create` (the same terms as `POST /operator/v1/free-rounds`);
  - `free-rounds.remove` (`{campaignId}`, active campaigns);
  - `tournament.create` (the same terms as `POST /operator/v1/engagement/tournaments`);
  - `jackpot.create` (the same terms as `POST /operator/v1/jackpots`);
  - `tournament.close` (`{tournamentId}`, only once its window has ended and before it is closing; closing fixes the leaderboard and starts prize delivery);
  - `campaign.create` (a budget campaign, the same terms as `POST /operator/v1/campaigns`);
  - `campaign.status` (`{campaignId, status}`: `paused` for a running campaign, `active` to resume a paused one that has not ended, `ended` for either; refused when proposed if not possible now).
-  Each module's own rules still apply at approval: for example, jackpot pools are refused while the operator has `engagement` on. The request expires after 1 minute to 24 hours (1 hour by default).
- A different enrolled approver decides with `POST changes/{id}/decision` `{decision, confirm, code, echo}`. `confirm` is the request's `termsSha256`; approving also needs `code`, a fresh authenticator code (`401 CONSOLE_STEP_UP_FAILED` otherwise).
- Approving what cannot be undone (stopping free rounds, closing a tournament, ending a budget campaign) also needs `echo: {target}`, the campaign or tournament code typed back by the approver (MASTER-PLAN H2; the console asks for it). The rules are the operator API's (`docs/api/operator-api.md`, changes).
- Every console page shows the database's birth profile and environment id (`database: {profile, environmentId}` in `me` and `login`).
- Approval also needs the maker to still be an approver: not revoked, and with a live bound key (`409` otherwise).
- Campaign and tournament codes from the console are `[a-z0-9][a-z0-9._-]*` (budget campaigns: the operator API's `[a-z0-9][a-z0-9_-]+`), so the approver reads exactly what runs.
- Approval runs the change in the same transaction under the checker's key. If its module refuses it, nothing is recorded and the request stays pending.
- Any approver can reject; the maker withdraws this way.
- Refusals are `CHANGE_REFUSED`:
  - 403: not an enrolled approver, or approving your own change;
  - 404: unknown change;
  - 409: already decided, expired, wrong `confirm`, or not applicable now;
  - 400: invalid terms, or an approval without the typed `echo` it needs, or with a different one (nothing changes).
- The console shows approvers how many changes wait for them: a badge on LiveOps and a card on the Overview.
- `GET changes` lists pending changes and recent decisions; each carries `subject`, what the change is about as people know it (the campaign or tournament code, or the game and version), so an approver never matches ids. `GET liveops-games` lists the operator's entitled, active game versions with their free-round offer and jackpot pools and tiers.
- The operator API offers the same requests and decisions under `/operator/v1/changes` ([Operator API](operator-api.md), Two-person changes). Where two people are required (module `two-person-changes`, an option the RGS operator sets per operator), its one-key routes for these actions refuse with `409 TWO_PERSON_REQUIRED`.
- Console keys carry the scopes their role needs: `campaigns` with LiveOps, `verifier` for admins. A role change moves the user to a fresh key and carries approver status.

**Console users through two people (migration 249).** Where two people are required (module `two-person-changes`):
- **One person is refused:** `POST users`, `POST users/{id}/role` and `POST users/{id}/reset` answer `409 TWO_PERSON_REQUIRED` with `{error, action}`. So does the bootstrap route `POST /operator/v1/console/users`. Disabling a user and signing them out stay one person, because they take access away.
- **Propose and decide:**
  - an admin proposes `console.user.create` (`{username, role}`), `console.user.role` (`{userId, role}`) or `console.user.reset` (`{userId}`) with `POST changes`;
  - a second admin who is an enrolled approver decides it under LiveOps, as above;
  - only admins see and decide these changes;
  - nobody proposes a change to their own account (`400`) or approves one (`409`, and the request stays pending);
  - the Users page proposes the change by itself when the route refuses.
- **The approval applies it** under the approver's key, through the same code as the one-person route. For a create or a reset, the approver's answer carries the user's one-time setup link (`result.setupToken`, `result.setupExpiresAt`). It is never stored with the change.
- **The operator API** refuses these actions: `403 CHANGE_REFUSED`, because console user changes are made in the console.

**Console API** (cookie-authenticated, under `/operator/console/api`):
- `login`, `logout`, `me`, `setup/info`, `setup`
- `alerts`, `markets` (finance), `players`, `players/data`, `players/wins`, `players/activity`, `rounds`, `rounds.csv`, `rounds/{id}`, `rounds/lookup`, `rounds/attention(.csv)`, `wins`
- `reports/daily(.csv)`, `reports/cohorts(.csv)`, `reports/liability(.csv)`
- `liveops`, `changes`, `changes/{id}/decision`, `liveops-games`
- `activity`, `activity.csv` (admins)
- `tournaments/{id}/standings`
- `wallet-verifications`
- `users`, `users.csv`, `users/{id}/role|disable|reset|sign-out`

## Errors

| Status | Code | When |
|---|---|---|
| 400 | `INVALID_REQUEST` | Unknown parameter, bad id, bad status, `limit` out of range. |
| 400 | `INVALID_BACKOFFICE_QUERY` | `from` not before `to`, window over 92 days, forged cursor, more than 16 currency accounts without a `currency` filter. |
| 401 | `OPERATOR_AUTHENTICATION_FAILED` | Bad, expired or revoked key, or missing `reports` scope. |
| 403 | `BACKOFFICE_DISABLED` | Module not enabled for this operator. |
| 403 | `BACKOFFICE_COHORTS_DISABLED` | Retention cohorts not enabled for this operator. |
| 403 | `DATA_PROTECTION_DISABLED` | Data protection (GDPR export and erasure) not enabled for this operator. |
| 409 | `PLAYER_ERASURE_REFUSED` | The player has a round or wallet operation that has not settled. |
| 404 | `BACKOFFICE_RESOURCE_NOT_FOUND` | No such player or round for this operator. |
| 422 | `BACKOFFICE_EXPORT_TOO_LARGE` | Export window holds more than 50,000 rounds. |
| 503 | `BACKOFFICE_STOPPED` | Kill switch active. |
| 403 | `WALLET_VERIFIER_DISABLED` | Verifier not enabled for this operator. |
| 409 | `WALLET_VERIFIER_UNAVAILABLE` | Operator is not in external wallet mode. |
| 503 | `WALLET_VERIFIER_STOPPED` | Verifier kill switch active. |
| 401 | `CONSOLE_LOGIN_FAILED` / `CONSOLE_UNAUTHENTICATED` | Console sign-in failed, or the session is missing, expired, idle or revoked. |
| 403 | `CONSOLE_FORBIDDEN` / `CONSOLE_DISABLED` | Role lacks the permission or missing CSRF header / console module off or stopped. |
| 400 | `CONSOLE_SETUP_INVALID` | Setup link invalid or expired, wrong code, weak password, or an admin changing their own account. |
| 409 | `CONSOLE_USER_EXISTS` | Username already taken for this operator. |
| 503 | `CONTROL_PLANE_BUSY` | The database admission budget is full; retry. |

## Scale notes

- Player searches use the existing `rounds(account_id, created_at desc, id desc)` index, so a page costs the same at 5 rounds or 5 million.
- Reports read the daily projection, never raw rounds. The worker advances it in bounded pages (25 operators × 1,000 facts per call) from `round_finalized_facts`, only while some operator has `backoffice` on, so settling a round does no extra work. Projection tables can be written only by the owner's consumer function.
- Liability reads only per-campaign and per-tier counters, a few rows per operator.
- Account-day rows (one per active account per day, used only to count distinct active accounts) are pruned by the worker 40 days behind each operator's projection position, never behind the wall clock, so a projection catching up on old history keeps what it needs. Daily totals, activity counts and cohort first days are kept.
- Session length is not reported: sessions record only start and close, and settled-round facts carry no session id. It needs an activity source that adds no per-spin write.
- At large scale, back-office reads should move to a read replica with its own connection budget. Until then each read is one short control-plane transaction under the shared fleet admission.
