Skip to main content

Exposure Limits — Cascade Model (CoW) + Agent v2 Invite UI

Context

Building the agent-v2 invite flow surfaced that the default/cascade + propagation model must be: caps live on the level that sets them, all upline caps stack (tightest binds), and downline track upline edits live (copy-on-write). This revises the committed per-player layer model (which materialized concrete per-player cells). The enforcement seam and orderService wiring are unaffected — only the resolver and storage change.

The model (agreed)

  • Cell = (sportId, marketTier, tier) → { maxExposure E, perBet PBL }; null=uncapped, 0=block-all.
  • A cap = a set of cells, set by exactly one level.
  • Caps live where they're set:
    • MA: an admin-baseline cap (the admin preset chosen at MA-invite, converted USD→MA currency, frozen) + an optional own downline cap.
    • Each sub-agent: an optional own downline cap.
    • Each player: an optional per-player custom cap.
  • Resolution(player, dimension) = walk player → direct agent → … → MA, collect every cap encountered (admin baseline + each level's downline cap + the player's custom); enforce all; tightest binds per cell. A level can only tighten, never loosen past its upline.
  • Copy-on-write / live: a level "on default" stores nothing — it resolves up the chain, so an upline edit applies to all default descendants immediately. "Customizing" writes that level's own cap (it does not detach upline caps — they still stack on top).
  • Currency: converted once at the admin→MA boundary; the whole tree is one currency thereafter, so the chain-walk needs no conversion.

Worked example (MA "M", INR; admin preset P0 = Cricket $2,000/$500 → ₹2,00,000/₹50,000)

Levels with a cap setCaps applied to the playerEffective (tightest)
Admin only (all default)admin ₹2,00,000/₹50,000₹2,00,000 / ₹50,000
Admin + MA custom (₹1,50,000/₹40,000)admin and MA₹1,50,000 / ₹40,000
Admin + MA + agent custom (₹1,00,000/₹30,000)all three₹1,00,000 / ₹30,000
Admin + MA + per-player custom (₹80,000/₹20,000)admin + MA + per-player₹80,000 / ₹20,000
  • M edits its cap → every default downline reflects it next bet (no copy). Customized levels keep their own cap but still inherit upline caps (which still stack).

Phase 1 — Backend redesign (foundation)

Schema (backend/prisma/schema.prisma + migration):

  • Keep ExposureLimitPreset (+cells) as the cell container (admin presets USD; agent caps MA-ccy; isDefault for the picker).
  • Caps on agents: add Agent.adminBaselinePresetId String? (MA only) and Agent.downlineCapPresetId String? (any agent) → reference ExposureLimitPreset.
  • Per-player custom: collapse the committed PlayerExposureLimitSet (drop the 3-layer dimension) to one optional per-player cap (or a new PlayerCustomCap table). Migration deprecates the old 3-layer rows. Services (backend/src/domain/exposureLimits/):
  • Rewrite resolveEffectiveLimits(playerId, dimension)chain-walk: collect admin baseline + each ancestor downlineCapPreset + player custom → return ResolvedLimitLayers (now a variable-length cap list). Cache the player→ancestor-cap-ids chain in Redis.
  • presetService: keep CRUD; add setAgentDownlineCap(agentId, cells) and setAdminBaseline(maId, adminPresetId) (does the USD→MA conversion, frozen).
  • inviteSeed: "Use Default" = no-op (chain-walk inherits); "Set Custom" (later) = write that level's cap.
  • Unchanged: enforce.ts (checkPerBet/checkMarketExposure over the cap list, most-restrictive) and the orderService wiring (Pmin/Wmax, gates) — they consume ResolvedLimitLayers, just fed by the new resolver.

Phase 2 — Admin: pick preset when inviting an MA

  • Extend the platform/MA-invite path (backend/src/routes/referrals.ts / admin portal) to let admin select a preset; on MA creation, setAdminBaseline(maId, presetId) converts its cells USD→MA-ccy (frozen) into the MA's adminBaselinePresetId.
  • Admin-portal UI: a preset dropdown on the invite-MA form.

Phase 3 — Agent v2 invite UI (the original ask)

  • GET /api/exposure-limits/effective-defaults → for the calling agent, chain-walk its own position to return the caps that WOULD apply to a new downline, grouped sport→tier→marketTier (+currency). Powers the Review grid.
  • Wizard (strykr-fe/src/components/agent-v2/InviteSheet.tsx, player + sub-agent): Basic Details (name, username, credit limit → initialBalance) → Exposure (Use Default preselected; Set Custom disabled) → Review (renders effective-defaults) → Success (existing credentials state).
  • Create via existing agentApi.invitePlayer / inviteAgent; "Use Default" writes no cap. New src/lib/exposureLimitsApi.ts (getEffectiveDefaults).
  • Entry already wired: src/app/agent-v2/downline/page.tsx → InviteSheet.

Out of scope (follow-ups)

  • "Set Custom Limits" grid + entry screens (104737/104905) + GET /grid; Save as Preset; Allowed Sports; Win Limit; deep (>3-level) cap UIs.

Impact / risks

  • Revises committed backend (commits for the per-player layer model): PlayerExposureLimitSet shape, resolveEffectiveLimits, inviteSeed, and the migration. nettingEngine, enforce.ts, and the orderService gates are preserved. (Historical note: this was originally behind EXPOSURE_LIMITS_ENABLED; that flag has since been removed and enforcement is always on for tier-tagged markets.)
  • Chain-walk resolution adds a bounded per-bet read (cached) vs the old O(1) per-player read.

Verification

  • prisma generate + migration; seed an admin preset; admin invites MA with that preset → MA adminBaselinePresetId set, cells in MA ccy.
  • Unit: chain-walk resolver across admin/MA/agent/per-player; tightest-binds; "level can only tighten"; CoW (edit MA cap → default downline effective changes, customized level keeps its own but still inherits).
  • E2E (agent-v2): Downline → Invite User → Player: name+username+credit → Review shows chain-walked defaults → Create → credentials; player appears with credit; agent balance decremented; PointAllocation written. Sub-agent invite via MA toggle.
  • Enforcement (flag on, temp): a bet breaching the tightest applicable cap is rejected; loosening a child cap above an upline cap does not relax the effective limit.