Appearance
Play Money & the Claim Board
How beta players get playSKPK (perpetual play money, no cash value) — the earn-to-play claim board that replaces the faucet. Config-driven, server-authoritative, and mints exactly once per earned claim. See the chip-claim-board decision for the full rationale and playSKPK for the on-chain grant.
The model
Each user has a set of claim sections. A section is a chunk of playSKPK gated by a condition, in one of three states:
- locked — the condition isn't met yet; the board shows what unlocks it.
- lit — claimable now.
- claimed — done.
Two kinds of section:
- Baseline — 500 playSKPK, refills weekly and does not stack past 500 (don't claim → don't accumulate). The recurring reason to come back.
- Unlock — one-time, escalating. Each lights when its condition is really met (an onboarding step now, a social task later).
The load-bearing rule: a section is lit only when its condition is really met (server-detected), and claiming a locked or already-claimed section grants nothing. Play chips are game value — the board must never be a free-mint. State is server-authoritative; the UI reflects it, never drives it.
Data model — three tables, three roles
The claim board is three tables, each with one job. Never conflate them.
| Table | Role | Shape |
|---|---|---|
claim_sections | Catalog (config, not code) | one row per section per environment |
user_claim_sections | State (per user) | one row per (user_id, section_key) |
claim_ledger | Audit (immutable) | one row per successful claim |
claim_sections — the env-scoped catalog
The section catalog is data an operator edits at runtime, not code — add a section, reorder, retune any amount, change the baseline, or disable a section with no deploy.
key,condition_key(null for the baseline),kind(baseline|unlock),amount,order,enabled,cadence(weekly|once),environment.- Unique on
(environment, key). - Environment resolution mirrors
game_settings:'all'is the every-deployment default (what the migration seeds), and a concrete-environmentrow (local/staging/production) overrides the'all'row for the same key. So staging can run different amounts than prod, and disabling a section on staging (anenvironment='staging'row withenabled=false) hides the seeded'all'row there without touching prod.
Reads go through @numero/db's getClaimCatalog(db, environment) / getClaimSection(db, key, environment) — env-pure (the caller passes its TABLE_ENVIRONMENT; the db package reads no process.env). Game-server's claim-board/catalog.ts resolves the deployment environment and loads through it, logging the resolved environment + loaded section count at boot so a mis-scoped or empty catalog is visible (never a silent zero-section board).
kind and cadence are IMMUTABLE once a key exists. An operator edits amount / conditionKey / order / enabled freely, but the operator write path (PATCH /admin/claim-sections) refuses a kind or cadence change on an existing (environment, key) with 409 kind_cadence_immutable. This is a fund-safety guard, not a nicety: kind/cadence determine the claim's period, which is part of claim_id = keccak(userId:sectionKey:period) — the claim_ledger UNIQUE that stops double-mint. Flipping a claimed unlock to a baseline would give the same paid section a new claimId and re-open it for a weekly re-mint. Defense-in-depth backs this at the claim path: an unlock pays a given user at most once, ever — the claim blocks (credits nothing) on any prior ledger row for that key, robust even to a delete-then-recreate. To genuinely change a section's kind, use a new key. (Amount is also validated as a non-negative integer — a fractional amount is a 400 invalid_amount.)
user_claim_sections — per-user state
The current, mutable state of each section for a user: state (locked | lit | claimed), lit_at, claimed_at, period, claim_id. Unique on (user_id, section_key).
For the recurring baseline, claimability is derived — the period column holds the window key of the last claim, and the baseline is claimable again once the current window is past that stored period. Nothing is cron-pushed; the window comes from DB time.
claim_ledger — immutable audit
One row per successful claim: user_id, section_key, amount, claim_id, grant_tx, created_at. The unique claim_id is the database-level no-double-credit floor (the cage's on-chain grant_marker PDA is the on-chain floor). The ledger is append-only; it feeds audit, PMF activation analysis, and anti-abuse.
The kill-switch
The whole feature is gated by claim_board_enabled (a game_settings global, fail-closed OFF), resolved per-environment — so the board, markConditionMet, and the claim endpoint all deploy dark, and turning it on in prod is a flag flip, not a deploy (the betting_enabled precedent). Staging can flip on while prod stays dark.
How sources light sections
A grant source (onboarding steps now, social tasks later) never touches section state. It fires a condition key through the one generic seam:
markConditionMet(userId, conditionKey, idempotencyKey)The engine lights every enabled catalog section bound to that conditionKey (locked → lit), idempotent by state — a lit or claimed section is never re-lit, so a source firing twice is a no-op. Config decides which keys pay and how much: a section's condition_key binds it to a source's event, and adding a new reward source is a catalog row + a new condition key with zero engine change (assertion A14 proves this by lighting a section for a never-before-seen key).
In-process sources call markConditionMet directly (the six onboarding conditions are detected inside game-server). Cross-service sources and tests use the service-key seam:
POST /internal/claim-board/condition-met { userId, conditionKey, idempotencyKey } (x-service-key)The condition keys the beta seed binds:
| Condition key | Fired when | Section | Amount |
|---|---|---|---|
wallet_linked | a Solana wallet is linked | connect_wallet | 500 |
agent_version_published | the first agent version is published | build_agent | 500 |
agent_seated | the agent takes a seat | seat_agent | 1000 |
hand_flagged | a hand decision is flagged | inspect_decision | 1000 |
agent_version_republished | a new version is published after a flag | tune_republish | 2500 |
Claiming — server-rechecked, never a free-mint
The claim endpoint is JWT-authed and identity comes solely from the JWT (never a body/query userId) — the claim mints money to the authenticated user.
GET /claim-board/state (JWT) — the caller's board
POST /claim-board/claim {sectionKey} (JWT) — claim a sectionclaimSection runs inside a transaction:
- The kill-switch and the section's live config are re-read (an operator's amount/enabled change takes effect here — A11/A13).
- The period key is computed (
oncefor unlocks, the ISO-week for the baseline), andclaim_id = keccak256(userId:sectionKey:period)is derived. - If a
claim_ledgerrow already exists for thatclaim_id→already_claimed, credits nothing (A8). - The section is re-verified claimable — an unlock must be
lit(an unmet or UI-bypassed claim islocked, credits nothing — A7/A9); the baseline must be un-claimed this period. - Only on a valid claim does it mint (the cage grant — Phase 3) and write
claimed+ the immutable ledger row.
claim.condition_met and claim.attempt spans record the flow; claim.attempt.outcome is one of credited | locked | already_claimed | forged.
The mint: claim → cage grant → ledger
On a valid claim, the mint is the cage playSKPK grant (claim-board/grant.ts). playSKPK is Solana-only, so this targets the CHAIN=solana deploy's BANK_URL (= solana-bank):
- Resolve the recipient — the user's linked Solana wallet, from auth-server (
GET /api/internal/user-wallets-solana/:userId). Fail-closed: no wallet (or any read failure) → no mint, no state change. - Grant —
POST {BANK_URL}/api/grant { recipient, amount, claimId }(service-key). The cage mintsamountplaySKPK to the wallet's ATA and returns the tx signature.claimIdis the deterministickeccak256(userId:sectionKey:period)— the cage's on-chaingrant_markerPDA makes it single-use. - Record — write the
claim_ledgerrow (withgrant_tx) + mark the sectionclaimed.
The order is load-bearing — the grant fires inside the claim transaction, and the ledger + claimed are written only on grant success:
- no linked wallet → fail-closed, empty commit (no mint, not claimed) — the user links a wallet and retries;
- cage / on-chain failure → throw → the tx rolls back (never claimed-without-mint) — retryable;
- success → mint + ledger + claimed, atomic.
No double-mint has two floors: the claim_ledger.claim_id UNIQUE short-circuits a re-claim (already_claimed, no second cage call), and the cage's grant_marker PDA is the on-chain floor (a replay returns { idempotent: true } and mints nothing).
Section state machine
The weekly baseline
The baseline section (500 playSKPK) is the recurring reason to come back. Its claimability is derived, not stored: each cadence has a period key — periodKeyFor("weekly", now) is the ISO-week key (2026-W29), "once" for unlocks. The baseline is claimable iff the user's last-claimed period differs from the current window:
claimable = !userRow || userRow.period !== currentPeriodSo at the start of a new ISO week the current period advances, the stored period is now stale, and the baseline lights up again — no cron, no scheduled job. On claim, period is stamped to the current window (and the claim_id = keccak256(userId:sectionKey:period) changes weekly, so each week's mint is distinct).
No stacking. A claim credits the section's window amount once — never a sum of skipped weeks. If you skip three weeks and then claim, you get 500, not 1500: the amount is the window's, and the period only records which window you last claimed, never how many you missed.
The period is computed from the process clock at claim time — not a hardcoded date. (A single-game-server beta; a strict DB-now() derivation is a trivial refinement if multiple game-servers ever need window-boundary consensus.)
The board UI — config-driven, affordance-gated
The player-facing board lives at /claim (linked from the profile menu) and is one component — ClaimBoard.tsx (apps/poker/src/poker/components/) over the useClaimBoard hook.
It renders whatever the catalog returns — never a hardcoded section list. The hook fetches the caller's board (GET /claim-board/state) and the component maps the returned sections: the baseline is the loud lime hero, the unlock sections are the ladder (sorted by order). A section added purely in config appears with no code change — an unknown key the component has no display copy for falls back to a humanized key, so a new reward surfaces the moment its catalog row lands (this is exactly what assertion A12 pins).
The claim action is affordance-gated on section.claimable, never validation-gated. A locked section shows its unlock copy; a claimed section shows a "claimed" badge; only a lit section (or the baseline in a fresh window) renders a claim button. There is no disabled-button state — the button is absent until the section is genuinely claimable, matching the bulletproof-state-machine affordance discipline. State is server-authoritative: the board reflects it, the UI never drives it.
The transport is the generic BFF proxy. The browser calls /api/gw/game/claim-board/{state,claim}; the same-origin proxy forwards to game-server's registerClaimBoardRoutes with the Bearer JWT unchanged (identity is JWT-only — the server never trusts a body userId). No dedicated BFF route was needed. Each claim is wrapped in a ui.claim.submit span (otel.category=ui.action, section.key, claim.outcome/error.code) — telemetry never blocks the claim; on success the hook refetches so the board reflects the new server state (claimed / period advanced).