# Free-Only Mystery Box System

## Scope and non-negotiable boundary

The Phase 13 Mystery Box is a task-qualified, free reward feature. It is not a paid-chance product. A user cannot pay, purchase, deposit, stake, or transfer value to obtain a claim. The database permanently constrains `access_cost_minor = 0` and `requires_purchase = 0`; the Admin API does not accept either field.

The web runtime exposes the boundary explicitly in public responses:

```json
{
  "access_cost_minor": "0",
  "free_only": true,
  "purchase_required": false
}
```

## Public API

| Method | Route                        | Behavior                                                               |
| ------ | ---------------------------- | ---------------------------------------------------------------------- |
| GET    | `/api/v1/mystery-box/status` | Returns the current free box, possible rewards, and server eligibility |
| POST   | `/api/v1/mystery-box/claim`  | Claims one eligible opportunity and atomically posts its ledger reward |

Both routes require a verified Telegram user session. Claim also requires CSRF, the sensitive-route rate limit, an empty JSON body, and a 16–128 character URL-safe `Idempotency-Key` header.

The user never submits a box ID, reward ID, reward amount, weight, random ticket, task count, period boundary, or eligibility timestamp. Those values are selected or calculated by the server.

## Eligibility frequencies

| Frequency  | Server rule                                                                     |
| ---------- | ------------------------------------------------------------------------------- |
| `daily`    | One claim inside the Admin-configured timezone's civil day                      |
| `weekly`   | One claim inside the timezone's Monday-to-Monday civil week                     |
| `lifetime` | One claim for the lifetime of that box                                          |
| `cooldown` | The next claim becomes eligible only after `last_claimed_at + cooldown_seconds` |

All day/week boundaries are converted to UTC. The browser clock is never used. Verified rows in `task_completions` are counted directly; client progress is display-only.

## Atomic claim flow

1. HMAC-digest the user-provided idempotency key and check for a committed replay.
2. Verify the per-user `mystery_box` feature flag.
3. Begin a MySQL transaction and lock the user row.
4. Reject inactive, deleted, account-banned, or earning-banned users.
5. Lock the one active, scheduled, database-constrained free box.
6. Count authoritative task completions and lock the user's latest claim.
7. Derive the daily, weekly, lifetime, or cooldown opportunity key from server time.
8. Lock active rewards that still have inventory.
9. Select the fixed reward or use `node:crypto.randomInt` for weighted selection.
10. Atomically increment `awarded_count` without exceeding `inventory_limit`.
11. Create an HMAC selection proof over the immutable box/user/reward/opportunity/ticket snapshot.
12. Debit the system `mystery_rewards` account and credit the user's `available` account through the ledger service.
13. Insert the immutable claim with eligibility, version, period, amount, currency, proof, and ledger snapshots.
14. Commit once. Any failure rolls back inventory, ledger rows, balance projection, and claim together.

The unique `(mystery_box_id, user_id, opportunity_key)` constraint blocks concurrent duplicate claims even when different idempotency keys are submitted. The user row lock prevents cooldown races. A repeated committed idempotency key returns the original claim.

## Reward selection

`fixed` mode requires exactly one active, in-stock reward. `weighted_random` requires at least two active, in-stock rewards. Each weight is an integer from 1 through 1,000,000, with at most 100 configured rewards per box.

Weighted selection uses an unbiased integer ticket generated by Node's cryptographic random source. `Math.random()` is not used. The public status response presents each currently available reward and its integer `chance_basis_points` for transparency. Inventory-exhausted rewards are excluded before selection.

Reward money is always stored and returned as integer minor-unit strings. Reward currency comes from the server's settlement currency and cannot be chosen by the client.

## Admin API and permissions

Admin routes are under `/api/v1/admin/mystery-boxes` and require Admin authentication, the configured Admin IP policy, CSRF on mutations, sensitive-route limits, and audit context.

| Permission                | Operations                                                 |
| ------------------------- | ---------------------------------------------------------- |
| `mystery_box.view`        | List boxes and view configuration/rewards                  |
| `mystery_box.manage`      | Create/update draft or paused boxes and rewards            |
| `mystery_box.publish`     | Publish, pause, end, or archive a box                      |
| `mystery_box.claims.view` | Read immutable claim, selection, user, and ledger evidence |

Only `draft` and `paused` boxes or rewards can be edited. Publishing verifies dates, frequency/cooldown consistency, settlement currency, free-only fields, reward count, weights, and inventory. A generated unique `active_slot` permits only one `active` box, including under concurrent Admin requests.

Box and reward updates use optimistic versions. Stale writes fail with `MYSTERY_BOX_STATE_CONFLICT`. Claims cannot be updated or deleted because database triggers reject both operations.

## Deployment checklist

1. Back up and identify the intended Oracle MySQL database.
2. Run `npm run migrate:dry-run` and review migration `018`.
3. Run `npm run migrate` and `npm run migrate:status`.
4. Create a draft box and its rewards using the Admin API.
5. Confirm every reward uses the active settlement currency and valid inventory.
6. Enable the `mystery_box` feature flag only for the intended rollout percentage.
7. Publish with an Admin holding `mystery_box.publish`.
8. Test status, claim, replay, ledger history, and dashboard state using a non-production test user first.

No live database or Telegram environment was accessed while Phase 13 was built.
