Appearance
The Cage
Status: under construction — this page grows with the
crypto-at-the-cageplan (Phase 1: the account model, below). The ADR is the decision record:.indusk/planning/crypto-at-the-cage/adr.md.
Crypto exists ONLY at the cage — the window where outside value is exchanged for chips and back. Every table-level money operation is a synchronous journal transaction; no table flow produces or awaits an on-chain transaction, and a brand-new user reaches a seat without ever seeing a wallet.
The account model (Phase 1)
A user's chips live in a cage account between tables. Two objects, one truth:
cage_ledger— the append-only journal. One row per movement (purchase, grant, claim, table buy-in/cash-out, bust close, custody, tournament entry/payout, bet stake/payout, adjustment), with a SIGNED amount, amoney_kinddiscriminator (play|real— ONE ledger for both economies), and a uniqueref_idmaking every movement replay-once. No UPDATE path exists anywhere.cage_accounts— the balance projection:(user, kind) → balance. Rebuildable from the ledger at any time; on disagreement the journal wins and the projection rebuilds. Abalance >= 0CHECK backs the engine's overdraft refusal at the database itself.
All writes go through one engine (applyCageEntry in @numero/db): lock the account row, refuse overdrafts, append the ledger row, bump the projection — one transaction. Concurrent duplicates of the same ref_id resolve as replays of the original outcome via the unique index.
Money flow (end-state)
Every movement in the journal box is one DB transaction — no intents, no reconcilers, no confirm states. Intent machinery survives only at the window, where chain asynchrony is real.
Conservation
Every chip is BORN at an exchange credit and DIES at redemption; at any instant it sits in exactly one of: a cage balance, a table seat, a custody hold, or the rake sink. The per-table conservation check keeps running; the cage adds its own term — cage_accounts.balance ≡ Σ cage_ledger per account — checked at boot and alarmed via the persistence.conservation.cage_divergence span. The remediation is always REBUILD the projection; nobody edits the ledger.
The route interface (Phase 2)
The cage implements the banks' HTTP interface behind BANK_URL — the flip is the cutover. Three route classes:
| Class | Routes | Behavior |
|---|---|---|
| Journal-native | /api/grant, /api/deposit, /api/withdraw, /settlement/reconcile-rake | Real cage-ledger legs, bank refusal codes kept (bad_request, real_money_disabled, missing_seat_index, nothing_to_withdraw, the fail-closed mid-hand 409s, …) |
| Ported reads | tables proxy, economy/demo/house flags, settlement reads, /internal/test/* seeds | Verbatim — they were already chain-free |
| Journal-native (Phase 6 wave) | /escape/:tableId, /api/custody/claim, /admin/custody/{dispute,release,holds,reconcile}, /api/betting/*, /api/gas-tank, /api/gas/{deposit,withdraw}, the sng branch of /api/deposit, the payoutIntent/forced branches of /api/withdraw | See "Full fund paths (Phase 6)" below |
| Pending 501s | operator/house seating (Phase 7 ops), /settlement/settle (parked — retires with the dealer, ADR D8) | Coded not_yet_cage naming the landing phase — never a 404 |
Deliberate divergences from the banks (they ARE the point): no wallet_not_linked (funds come from the user's own cage balance), insufficient_cage_balance (402) replaces wallet-side token failures, no confirm states (status: "confirmed" immediately — the debit is one transaction), and a failed downstream push compensates rather than parking an intent for a reconciler.
Rake: a negative term on the table invariant (balance = deposits − withdraws − rake). "Collected" is a ledger calculation — reconciliation claims the house rows and extends the hash chain (same bytes as the chain era, so old links anchor new ones), with no money movement anywhere.
The pull seam (Phase 3)
Funding happens inside the one request the player initiated — no permit, no confirm wait, no callback direction:
Stand-up reverses it server-side (the cage's hardened /api/withdraw writes the terminal table_cash_out row and the settle zeroes the rows); a bust writes the explicit-zero bust_close row and CLOSES the position chain (the dangling-chain fix). top_up rides the same seam between hands. With the cage down: sits refuse cage_unavailable within seconds and recovery follows restart (A16, proven by stopping the container).
The zero-wallet UI (Phase 4)
useCageMode reads the money service's own flags (fail-closed) — the UI flips the moment BANK_URL does. On cage deploys <CageSitFlow> renders in DepositFlow's slot: sign-in is the only prerequisite, the affordance gates on the user's cage balance (too low → "Claim chips"), the buy-in funds on the WebSocket round-trip, and every refusal is a routed coded response. The claim board grants wallet-less users directly (a journal credit keyed by userId). DepositFlow and the chain seam remain load-bearing for chain-bank deploys until the demolition wave.
Receipts (Phase 5)
Every settlement mints a signed receipt the player actually HOLDS — the chain era signed receipts as transaction arguments and players held nothing. Verify one offline with nothing but the receipt and the published key:
GET /api/cage/receipt-key→{ algorithm: "ed25519", publicKey }- Your receipts:
GET /api/cage/receipts(your own JWT) - Check:
ed25519.verify(payload_bytes, signature, publicKey)— thepayloadfield is the exact signed bytes, stored verbatim, so verification never depends on re-deriving field order.
The receipt binds the settle to the hand history (historyHash = the position-chain head) and to the journal (ledgerRefId = the terminal ledger entry). One receipt per settle — a replayed settle returns the ORIGINAL, never a contradicting second one. Busts get their explicit ZERO receipt: the session-closure proof.
Full fund paths (Phase 6)
Every remaining money flow became a journal move — same interfaces, same refusal ladders, no chain legs anywhere.
Custody + escape. The frozen-table reaper's POST /escape/:tableId works journal-natively: the per-player snapshot still comes from game-server (conservation-clamped at its ONE chokepoint — whose credited/withdrawn terms are now era-additive: deposit-intents for chain-funded tables PLUS cage_ledger legs for cage-funded ones), and the "pool" it reconciles against is credited − withdrawn − rake − custodyby construction — there is no vault, so the banks' unreconciled-rake allowance (the C2UFJ4/ZDA9W3 fix) dissolves: rake was never inside the pool to begin with. Clean books → per-player FREE custody_entries rows (chain='cage', base units — the shared conservation readers divide by 1e6); divergent books → ONE needs_reconciliation hold with the per-player ledger preserved for /admin/custody/reconcile (pro-rata or custom split, conservation-guarded Σ == pool). A FREE claim (POST /api/custody/claim) pays its OWNER — identified by JWT, no destination wallet exists — straight into the cage balance as a custody_claim journal credit (replay-once on the entry's refId) plus a delivered receipt. DISPUTED stays a two-party gate in shape: release requires the service key AND the arbiter's detached ed25519 over the canonical release message (CAGE_CUSTODY_ARBITER_PUBKEY; unset → coded 503, never a bypass). Forced withdraws (idle_kick) move the stack to a FREE custody row the same way — never a forced payout to anywhere.
Tournaments. The sng branch of /api/deposit keeps the banks' entire refusal ladder (entry-fee gate, registration pre-check, audience/advisor gates, race-safe claimTournamentSeat under the same advisory lock — so seat counting is identical across eras) and swaps the money leg for a tournament_entry journal debit; an overdraft releases the held seat. The payout dispatch (/api/withdraw + payoutIntent) loses its chain wait entirely: the prize is one tournament_payout journal credit whose refId is deterministic (payout:{tableId}:{position}) — the journal itself refuses a double-pay, on top of the banks' row guards (confirmedAt replay, submittedAt reconcile, workflow UPSERT) which are kept verbatim. The tournament FEE never moves: journal-natively the house's cut is a ledger calculation (entries − payouts), exactly like rake.
Betting. betting-server stays the chain-unaware DPM brain; the cage's money legs are journal moves. A stake is a bet_stake debit plus the LOUD off-chain credit notify (a failed notify compensates the debit — money never sits unspendable); a claim's amount comes from betting-server's OWN claimable computation on the JWT path (never the request body — the theft vector rule), credited as bet_payout single-use via the deterministic refId (bet-claim:{marketId}:{userId} — the journal's claim_marker); an unbet withdraw debits betting-server FIRST (its atomic conditional decrement is the single authority on the user's share of a shared pool) then credits the journal; a freeze-settle writes per-bettor FREE custody rows redeemed via the verbatim claim path. Market create is a journal- native no-op (there is no vault — the pool IS the sum of stakes); open/bonding/resolve pass through to betting-server unchanged.
Gas (ADR D8). The gas tank rehomes to the cage: gas_deposit / gas_withdraw journal legs move chips between the cage balance and the unchanged micro-unit gas_tanks ledger (reserve-first on the way out, compensating refund on failure). No signer wallet, no linkage.
Coming in later phases
- The
apps/cageservice and its bank-interface refusal parity (Phase 2). - Full fund-path parity (Phase 6), the staging cutover (Phase 7), and the demolition wave (Phase 8).