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 set | Caps applied to the player | Effective (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;isDefaultfor the picker). - Caps on agents: add
Agent.adminBaselinePresetId String?(MA only) andAgent.downlineCapPresetId String?(any agent) → referenceExposureLimitPreset. - Per-player custom: collapse the committed
PlayerExposureLimitSet(drop the 3-layerdimension) to one optional per-player cap (or a newPlayerCustomCaptable). Migration deprecates the old 3-layer rows. Services (backend/src/domain/exposureLimits/): - Rewrite
resolveEffectiveLimits(playerId, dimension)→ chain-walk: collect admin baseline + each ancestordownlineCapPreset+ player custom → returnResolvedLimitLayers(now a variable-length cap list). Cache the player→ancestor-cap-ids chain in Redis. presetService: keep CRUD; addsetAgentDownlineCap(agentId, cells)andsetAdminBaseline(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/checkMarketExposureover the cap list, most-restrictive) and theorderServicewiring (Pmin/Wmax, gates) — they consumeResolvedLimitLayers, 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'sadminBaselinePresetId. - 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 (renderseffective-defaults) → Success (existing credentials state). - Create via existing
agentApi.invitePlayer/inviteAgent; "Use Default" writes no cap. Newsrc/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):
PlayerExposureLimitSetshape,resolveEffectiveLimits,inviteSeed, and the migration.nettingEngine,enforce.ts, and the orderService gates are preserved. (Historical note: this was originally behindEXPOSURE_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 → MAadminBaselinePresetIdset, 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;
PointAllocationwritten. 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.