# Campaign API — local successor contract

This October 5, 2026 delta describes the locally implemented/tested Q-CAMPAIGNS source. It supersedes campaign behavior in the September reference; it is **not** a deployment or a regenerated OpenAPI/HTML/PDF/portal release. [Current engineering status](../../STATUS.md) and [implementation/evidence contract](../workstreams/campaign-ownership.md) govern observed readiness.

## Player grant history

`GET /v1/campaigns` requires a valid player Bearer session. The account is taken from that authenticated session, never from a query account identifier.

| Query | Contract |
|---|---|
| `limit` | Optional integer, 1–100. Send `limit=50` to start a paginated traversal. |
| `cursor` | Optional continuation returned by the previous page; at most 512 characters. With cursor but no limit the page size is 50. Preserve it verbatim and URL-encode it. |
| No query | Compatibility mode: succeeds only if the entire account history fits within 100 grants. More history returns HTTP 409 `CAMPAIGN_PAGINATION_REQUIRED`; retry with explicit pagination. It never returns the newest 100 as if complete. |

Unknown query fields and invalid parameter shapes fail request validation with HTTP 400. Semantically invalid, malformed, noncanonical or foreign-account cursors return HTTP 400 `{ "error": "INVALID_CAMPAIGN_CURSOR" }`. The cursor is a continuation position, not authorization, a cryptographic attestation or a cross-request snapshot.

A successful HTTP 200 body is `{ "grants": [...], "nextCursor": null-or-string }`. Each grant has `grantId` (UUID), `campaignId` (UUID), `code` (campaign code), `amount` (positive safe integer in currency minor units), `currency`, and `grantedAt` (UTC ISO timestamp with six fractional digits). `nextCursor: null` means this page has no older continuation in its database statement snapshot. Keep requesting the returned cursor until it is null; never infer completeness merely from a short page.

Rows are ordered by `created_at DESC, id DESC`, preserving PostgreSQL microseconds and resolving timestamp ties deterministically. Each page uses one MVCC statement snapshot; pages do not hold a database transaction open between requests. New grants newer than the current position are observed by starting again at the first page, not inserted into an older continuation. Historical metadata is read from the campaign at page time. There is no multi-page export-snapshot guarantee.

The query fetches at most `limit + 1` indexed grant rows before metadata lookup. Persisted oversized/invalid selected history fails closed with HTTP 422 `{ "error": "CAMPAIGN_UNAVAILABLE" }`, not a partial success. Campaign code output is capped at 256 UTF-8 bytes and the serialized body at 65,536 UTF-8 bytes; a smaller explicit limit can resolve a page-byte overflow but cannot repair invalid stored rows. This is a bounded history API, not an unbounded bulk export.

The service's read operation has a 2000 ms budget and uses the existing bounded checkout/admission/SQL owner. Busy reads return HTTP 503 `{ "error": "CONTROL_PLANE_BUSY" }` with `Retry-After: 1`. Authentication has its own existing boundary and does not turn this service budget into an end-to-end network deadline.

## Operator mutations and retry identity

Operator routes still require a Bearer key with `campaigns` scope. Existing body schemas and status transitions are retained.

| Route | Success / ownership |
|---|---|
| `POST /operator/v1/campaigns` | HTTP 201 `{ id }`; bounded control-plane transaction, including game lookup and action audit. |
| `POST /operator/v1/campaigns/:campaignId/grants` | HTTP 201 for a new grant or 200 for an exact retry; `{ created, balance, grantId }`. Shares gameplay's actual-account admission. |
| `PUT /operator/v1/campaigns/:campaignId/status` | HTTP 200 `{ id, status }`; bounded control-plane transaction with action audit. |

Grant idempotency is scoped to the campaign and key, and binds the authenticated operator, resolved recipient account and amount. Reusing the key with a different recipient or amount returns HTTP 422 `CAMPAIGN_UNAVAILABLE`. An exact existing grant is recoverable when the campaign is paused/ended/expired and returns the **original ledger entry's post-grant balance**, not the account's later current balance. It does not grant again, consume budget again or append another outbox/action-audit effect. Read current funds through the existing balance API when needed.

The grant lookup and mutation share one 6000 ms total service deadline, including waiting. The lookup owner is released before acquiring the actual account owner; recipient/operator/currency/account identity is checked again under locks. Commands and nested audit identity are copied before waiting. A new grant atomically commits ledger/account credit, the grant row, awarded budget, outbox and action audit.

Read/create/status SQL unavailability returns `CONTROL_PLANE_BUSY`; grant SQL unavailability returns `MONEY_PATH_BUSY`. Both are HTTP 503 with `Retry-After: 1` (authentication may independently return its existing busy response). A timeout or lost COMMIT acknowledgement is not evidence that money was rejected. Reconcile/resend a grant only with the **same original campaign, player, amount and idempotency key**. Do not switch keys to work around a busy response. Create/status operations are not newly idempotent; reconcile authoritative state before repeating an ambiguous mutation.

## Migration and evidence boundary

`074_campaign_grant_history.sql` adds `(account_id, created_at DESC, id DESC)` without rewriting historical grant/ledger identities or adding runtime privileges. Ordinary index creation may block writes: apply through the existing controlled maintenance procedure, not an assumed online upgrade. Populated disposable-database upgrade and restricted-role reads passed; no persistent environment was upgraded.

Real authenticated loopback HTTP tests exercised pagination, validation, missing authentication, all four busy service boundaries and retry after lock release. PostgreSQL tests cover precision/ties, account binding, live continuation, oversized/corrupt storage, atomic rollback, retry equivalence and history query work. See the workstream for exact commands, retained RED/harness failures and source identity. This is local execution evidence, not external-provider parity certification, independent review or production capacity.
