# Free rounds (M2)

First-party operator APIs, not a Tequity wire-format/bulk API claim. Existing cash-adjustment campaigns are unchanged. All amounts are integer minor units; liability/budget values use decimal strings. Only custodial and explicitly supported external one-shot rounds are supported. Interactive/multiplayer promotional funding remains refused.

## Configuration and admission

The immutable game-version configuration must declare `lifecycle: one-shot`, `betLimits.maximumWin`, and:

- `freeRounds.format: free-rounds-v1`
- `currency`: exact uppercase three-letter currency
- `baseAction`: the game's declared base action (1–64 characters)
- `betAmounts`: nonempty, unique positive safe integers
- `payoutCategory`: `promo` or `normal`

Campaigns cannot override the action, maximum payout or category, or select an unlisted amount. Other actions, including bonus buys, are refused. Campaign version, currency, amount, payout category, budget and time window are immutable. Removal cannot be undone.

Per-operator flags are absent/off by default: `free-rounds`, and additionally `free-rounds-external` for external funding. Both feature scopes and ordinary H1 admission stops apply. The external provider must explicitly advertise promotional-round support. The native signed wallet supports the tested zero-debit protocol; other providers default to unsupported. This is not live-provider qualification. No production flag is enabled by these migrations.

## Operator endpoints

All require an authenticated operator bearer with `campaigns` scope. Identity comes exclusively from that key, never the body. Unknown body fields are rejected. Mutations use the existing bounded audit transaction.

- `POST /operator/v1/free-rounds`
  - Body: `code`, `gameVersionId`, `currency`, `amount`, `payoutCategory`, `budgetMinor`, `startsAt`, `endsAt`.
  - Dates are ISO timestamps; end must follow start. Budget is a positive decimal integer no greater than `Number.MAX_SAFE_INTEGER`; it is explicit, with no default.
  - Returns `{id}`; 201 on creation, 200 on an exact same-code retry. Changed terms conflict.
  - Requires an enabled game entitlement, supported currency and the pinned version's free-round policy.
- `POST /operator/v1/free-rounds/:campaignId/grants`
  - Body: `playerId`, positive integer `count` (at most 2147483647), `idempotencyKey` (1–128 characters).
  - Player must already have the operator's account in the campaign currency. Reserves `count × maximumWin`, using exact SQL numeric comparison before bigint storage.
  - Returns `{grantId}`; 201 on creation, 200 for identical retry. A reused key with different player/count is refused; exhausted budget rolls back.
- `POST /operator/v1/free-rounds/:campaignId/remove`
  - Body: optional `idempotencyKey` (1–128 characters, no NUL or lone surrogate); the body itself may be omitted. Unknown fields are rejected. With `Content-Type: application/json`, an empty body answers 400: send `{}` or omit both the body and its content type.
  - Returns `{removed:true}`. Removal is idempotent by campaign identity: removing a removed campaign returns `{removed:true}` again and appends no journal row and no audit action. The key was once required but never used; it is still accepted and checked against its bounds, so existing callers keep working, and it identifies nothing.
  - Releases only the maintained unaccepted exposure. Does not scan historical grants or cancel accepted rounds. Works with promotional flags disabled.
- `POST /operator/v1/free-rounds/available-bets`
  - Body: `gameVersionId`, `currency`.
  - Returns `{gameVersionId,currency,action,amounts,payoutCategory,maximumPayoutMinor}` from the entitled pinned version. Read-only.
- `GET /operator/v1/free-rounds/:campaignId/liability`
  - Returns `{availableMinor,unpaidMinor,paidMinor}` from the maintained campaign projection.
  - Available is zero after expiry/removal. Unpaid accepted outcomes remain reported in full. Budget is an authorized liability limit, not evidence of bank/provider funds or escrow.

Unavailable/disabled/expired/mismatched terms and domain conflicts return HTTP 409 with `FREE_ROUNDS_UNAVAILABLE`; invalid input retains the standard validation response. Ordinary authorization, RG, catalog and database failures retain their existing distinct handling; SQL infrastructure defects are not converted into promotional refusals.

## Player inventory and execution

`GET /v1/free-rounds?limit=20&cursor=...` requires a player session bearer. Limit is 1–100. Returns `{grants,nextCursor}`. Cursor is opaque, strictly validated and account-bound. Rows contain `id`, `remaining`, `campaignId`, `gameVersionId`, `currency`, `amount`, `payoutCategory`, `action`, `endsAt`. Historical rows remain visible; expired/removed/not-yet-started entitlements expose zero available `remaining`. This avoids scanning all old grants to find currently usable ones. Pages use a bounded index walk before campaign lookups.

Add `freeRoundId` (the grant UUID, not campaign UUID) to normal `/v2/play` or `/v2/recover` commands. Legacy `/v1/play` also binds it. The original nominal wager/currency/action must match pinned terms. Omission preserves historical cash request bytes; an unknown or invalid grant never falls back to cash. Same command identity with another grant conflicts.

Nominal wager drives game math and the single-wager RG limit; promotional funding adds zero cash-loss exposure. Self-exclusion, reality checks, session, entitlement and ordinary H1 admission remain enforced. Accepted-command recovery precedes new-grant eligibility checks.

Custodial funding creates no cash debit. Positive winnings credit exactly once in the pinned category; zero winnings create no zero-value ledger entry. Consumption, outcome, credit and final fact share the settlement transaction. H1 cap review consumes the entitlement but retains the full unpaid amount and emits no final fact.

External funding sends a round-bound zero-amount `promo` debit, then the pinned category credit for positive payout. Other zero-money operations remain refused. Unknown outcomes are reconciled by the existing wallet worker using the original operation IDs; player recovery never invents provider confirmation. Declined promotional debit/credit retains the consumed outcome and full liability for manual review, without cancellation or reroll. Removal/expiry/disable does not erase accepted work.

## Audit, checker and safe state

Create/grant/remove append transactional `free_rounds.create`, `free_rounds.grant`, `free_rounds.remove` evidence with the authenticated actor and actual target. Exact retries do not append duplicate economic actions. Authorization tests cover wrong scope, cross-operator targets and body actor spoofing. H2 classification: creation and removal need two people where two people are required (module `two-person-changes`, an option the RGS operator sets per operator). The one-key routes then refuse with `409 TWO_PERSON_REQUIRED`, and the change goes through `/operator/v1/changes` ([Operator API](operator-api.md), Two-person changes). Grants stay one key within the approved budget. Reads are read-only, and play is a player wager. There is no invented large-budget threshold.

Migrations `081_free_rounds.sql` and `082_free_round_checker.sql` protect economic authority, immutable bindings, consumption/journal/source evidence and runtime privileges. The worker invokes `check_free_round_campaigns(25,512)` under the existing default-off `invariant-checker` flag. It rotates enabled operators, takes bounded campaign/source pages, and independently sums append-only journal deltas. A completed comparison detecting projection drift atomically stops that operator and appends `safety.free-rounds.violated` to the outbox. Accepted recovery remains allowed. Partial journal pages are not mistaken for complete projections. Source heads serialize with campaign money mutations, so a cursor cannot pass an uncommitted lower entry.

The migrations are forward-only once accepted promotional rounds exist: do not remove their authority or erase owed outcomes. Safe operational rollback is to disable both promotional admission flags while retaining these schemas and the recovery-compatible runtime. No deployment, restore, production rollback, live-wallet approval or certification is implied by local tests.

Evidence: `.runtime/master-v2-20261011T235602Z-fa5820/gates/m2-final-affected2/` — 241 affected integration and 4 compliance unit passes, typecheck, both manifest verifiers. Dedicated tests include real expiry, independent-Pool final-entitlement race, rollback of terminal fact failure, signed-wallet uncertainty, retained persistent-state reservation after promotional debit refusal, restricted runtime roles, immutable pins and indexed 512/4096 inventory/liability/version/journal reads. Full-suite status remains separately tracked in the master plan.
