diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1bfe3b9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.venv +__pycache__ +data +evaluation +*.db +*.db-shm +*.db-wal +docs +.github +console +node_modules diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34dd0b0..31cb131 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, v2] pull_request: permissions: @@ -37,6 +37,57 @@ jobs: - name: Tests with coverage run: python -m pytest -m "not slow and not bedrock" + docker: + name: container stack + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build API image + run: docker build -t sentinel-api . + - name: Boot API and verify serving + env: + SENTINEL_API_KEY: dev-sentinel-key + run: | + docker run -d --name api -p 8000:8000 sentinel-api + for i in $(seq 1 30); do curl -sf http://localhost:8000/healthz >/dev/null && break; sleep 2; done + curl -sf http://localhost:8000/healthz + curl -sf -X POST http://localhost:8000/v1/events -H "X-API-Key: $SENTINEL_API_KEY" -H "Content-Type: application/json" -d '{"event_id":"ci-1","merchant_id":"m1","customer_id":"c1","amount_paise":100,"ts":"2026-08-20T10:00:00+05:30","payment_method":"upi"}' | grep -o '"verdict":"[A-Z_]*"' + docker rm -f api + - name: Build console image + run: docker build -t sentinel-console ./console + + postgres: + name: postgres swap + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: sentinel + POSTGRES_DB: sentinel + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install pinned dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Postgres integration tests + env: + PGPASSWORD: sentinel + run: | + export SENTINEL_TEST_DATABASE_URL="postgresql+psycopg://postgres:${PGPASSWORD}@localhost:5432/sentinel" + python -m pytest -m postgres --no-cov -q + secrets: name: secret scan runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 361694b..629b30f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,9 @@ Thumbs.db node_modules/ .next/ out/ +console/out/ # Spool files from degraded-mode ingestion *.spool console/tsconfig.tsbuildinfo +CLAUDE.md diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..91b863e --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,6 @@ +# Gitleaks false positives: the flagged values are public demo defaults +# (dev-sentinel-key in a CI curl header, then a CI dummy postgres URL), +# not credentials. Historical commits cannot be edited without rewriting; +# the current content uses env vars so no new findings appear. +046547217cbe771646c62adcae94129a8a8d613c:.github/workflows/ci.yml:curl-auth-header:50 +50c2afd5bbc4ad81bcb505218e42e20b53a1963c:.github/workflows/ci.yml:curl-auth-header:50 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 3c52fd3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,184 +0,0 @@ -# CLAUDE.md - -## Section 1: Behavioral Guidelines (Karpathy Rules) - -Behavioral guidelines to reduce common LLM coding mistakes. - -### 1. Think Before Coding -Don't assume. Don't hide confusion. Surface tradeoffs. -- State your assumptions explicitly. If uncertain, ask. -- If multiple interpretations exist, present them - don't pick silently. -- If a simpler approach exists, say so. Push back when warranted. -- If something is unclear, stop. Name what's confusing. Ask. - -### 2. Simplicity First -Minimum code that solves the problem. Nothing speculative. -- No features beyond what was asked. -- No abstractions for single-use code. -- No "flexibility" or "configurability" that wasn't requested. -- If you write 200 lines and it could be 50, rewrite it. - -### 3. Surgical Changes -Touch only what you must. Clean up only your own mess. -- Don't "improve" adjacent code, comments, or formatting. -- Match existing style, even if you'd do it differently. -- Every changed line should trace directly to the user's request. - -### 4. Goal-Driven Execution -Define success criteria. Loop until verified. -- Transform tasks into verifiable goals. -- For multi-step tasks, state a brief plan with verification checks. -- Strong success criteria let you loop independently. - ---- - -## Section 2: Your Identity - -You are a Senior Staff Engineer at Razorpay. You are building production infrastructure, not a hackathon project. Everything you build must be: -- Production-grade (billions of transactions scale) -- Explainable (every AI decision has an audit trail) -- Bounded and gated (no uncontrolled money actions) -- Honestly measured (real metrics, no cherry-picking) - -You think like a Razorpay engineer. You document like a Razorpay engineer. You build like a Razorpay engineer. - ---- - -## Section 3: The Builder (Context) - -The person you are working with is Prakhar Shukla. Here is his background: - -- B.Tech Computer Science, S.B. Jain Institute, Nagpur. Graduating 2026. -- AWS AIdeas Top 50 Global Finalist (10,000+ teams worldwide) -- Top 2% National Finalist at India AI Impact Buildathon (40,000+ participants) -- National Winner at IIT Delhi (15,000+ teams) -- 2 IEEE published papers on Deepfake Detection -- Tech stack: Next.js, FastAPI, TypeScript, Python, AWS (Lambda, DynamoDB, Bedrock, SAM), LangGraph -- Has $550 in AWS credits -- Uses AI natively across the entire workflow - ---- - -## Section 4: The Competition (Razorpay AI Buildathon) - -The goal: build the strongest possible Track 02 submission as a portfolio piece. - -Deliverable requirements: -- A working project in a public GitHub repo -- A 5-minute pitch video -- Architecture documentation -- "What broke, and how you got out" answer - -Evaluation criteria (self-imposed bar, build to this standard): -- Problem taste: did you pick something that actually matters? -- Build quality: does it run, is it structured, would you trust it? -- AI judgment: the right tool in the right place, and where you chose NOT to use AI -- Failure recovery: what broke, and what you did about it - -### Track 02 — AI Risk Manager -Build a working detector, verifier or auto-responder for one class of loss, with measured precision and recall on a held-out test set. -Bar: Honest metrics including false-positive cost. Strictly defense-only. -Direction: Abuse-ring sentinel — detect the same UPI ID, phone number, or device ID being reused across multiple merchants/customers to commit fraud. - ---- - -## Section 5: Available AWS Bedrock Models - -| Provider | Models | Status | -|----------|--------|--------| -| Mistral | Large 3 (675B), Ministral 3 14B | ✅ | -| OpenAI | gpt-oss 120B, gpt-oss 20B | ✅ | -| Z.AI | GLM-5, GLM-4.7 | ✅ | -| Moonshot | Kimi K2.5 | ✅ | -| NVIDIA | Nemotron Super 3 120B | ✅ | -| Qwen | Qwen3-Next 80B | ✅ | -| Meta | Llama 4 Maverick/Scout, Llama 3.3 70B | ✅ | -| Amazon | Nova Micro/Lite/Pro/2-Lite | ✅ | -| DeepSeek | V3.2, R1 | ✅ | -| Google | Gemma 3 27B/12B | ✅ | -| Writer | Palmyra X5 | ✅ | -| Anthropic | All models | ❌ Blocked | - -Pick the model(s) that best fit entity extraction (structured JSON output) and explanation generation. Justify your choice in ARCHITECTURE.md. - ---- - -## Section 6: Your Mission - -Track 02 (Abuse-Ring Sentinel) is already confirmed. Do not re-evaluate other tracks. Proceed directly to design. - -### Step 1: Design the System -Write a complete ARCHITECTURE.md file. Include: -- Problem statement (1 paragraph) -- System design (data flow, described in text) -- Tech stack decisions with justifications -- Which AWS Bedrock model(s) to use and WHY -- Database schema -- API design -- How precision/recall/false-positive-cost will be measured -- Failure handling strategy -- What is NOT being built and why (scope boundaries) - -### Step 2: Build the System -Implement it. Production-grade code. -- Proper folder structure -- Type hints on every function -- Docstrings on every file -- Environment variables for all secrets — see Section 7 security rules -- Error handling everywhere -- Tests for core logic - -### Step 3: Create the Test Set & Evaluation -Generate synthetic data (900 clean, 100 fraud transactions, realistic patterns). Run the evaluation. Produce honest metrics: precision, recall, F1, false-positive cost in INR. - -### Step 4: Write the README.md -- One-line description -- Architecture diagram (ASCII) -- Setup instructions (exact commands) -- How to run the evaluation -- Metrics table -- "What broke and how I fixed it" section -- Design decisions and tradeoffs - -### Step 5: Prepare the Pitch Script -5-minute structure: -- 0:00–0:30 — The problem -- 0:30–1:30 — The solution / architecture -- 1:30–3:00 — Live demo -- 3:00–4:00 — Metrics and honest evaluation -- 4:00–4:30 — What broke and how I fixed it -- 4:30–5:00 — What I'd do next - ---- - -## Section 7: Constraints - -- NO AI slop. No generic chatbot wrappers. No demo-only projects. -- Every AI decision must be explainable. -- Show what broke. Show how you fixed it — genuinely, don't invent a failure. -- Use AWS Bedrock models only. No external paid APIs. -- Must run locally without deployed infrastructure. -- **No line-count cap** (owner decision, 2026-08-22). Discipline instead: every module must deliver a measured capability, a test, and documentation. Signal density over volume; no speculative features or abstraction. -- **Bedrock budget:** soft budget of $20 for the whole build. Never call Bedrock inside unbounded loops or retry storms; every script that spends money has an explicit call cap and prints what it spent. Verification calls are one-shot per model. - -**Security rule (non-negotiable):** -- Use the default AWS credential chain via boto3 (`boto3.client(...)` with no explicit keys). Credentials are already configured in the local AWS CLI. -- NEVER read, print, `cat`, or echo the contents of `~/.aws/credentials`, `.env` files, or any variable containing SECRET or KEY, for any reason including debugging. If auth fails, report the error message only, never the credential values. -- NEVER commit `.env`, keys, or account identifiers to the repository. Gitleaks runs in CI on every push. - ---- - -## Section 8: Build Protocol (phase-gated) - -The design phase is complete (see `/docs`). Code is now built in phases per `docs/11-roadmap.md`, with this loop per phase: - -1. **Build** the phase scope only. -2. **Verify** locally: `ruff check`, `ruff format --check`, `mypy`, `pytest` all green, plus the phase's checkpoint from doc 11. -3. **Push** with a professional conventional commit (`type: summary`, e.g. `feat: seeded generator with ring-stratified splits`). **Never put phase labels (phase-0, phase-1, ...) or roadmap jargon in commit messages**; describe what the change does, not which roadmap step it belongs to. -4. **Report** what was built, verification results, and anything that broke (append to `docs/what-broke.md` in real time). -5. **Wait** for the owner's explicit go before the next phase. - -Rules that hold across all phases: -- Modern repo management: src-layout package, pinned `requirements.txt` (lockfile), CI on every push, no direct commits to main that skip local checks. -- Latest stable versions of all dependencies; pins refreshed deliberately, never silently. -- `docs/what-broke.md` freshness is enforced by a pre-commit hook. Log failures as they happen; never invent or polish them afterward. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7be6d6b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# Abuse-Ring Sentinel API service. +FROM python:3.12-slim AS base + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY pyproject.toml README.md ./ +COPY src ./src +RUN pip install --no-deps . + +EXPOSE 8000 + +HEALTHCHECK --interval=10s --timeout=3s --retries=6 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)" + +CMD ["python", "-m", "uvicorn", "sentinel.service:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 22317a7..1093e73 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup check lint format type test test-all console-setup console serve backfill calibrate evaluate models hooks clean +.PHONY: setup check lint format type test test-all console-setup console serve backfill snapshot merchant-token challenger loadtest calibrate evaluate models hooks clean # One-time setup: virtualenv, pinned deps, git hooks. setup: @@ -51,6 +51,23 @@ serve: backfill: .venv/Scripts/python -m sentinel.backfill --limit 20 +# Zero-backend static demo snapshot into console/out/ (hostable anywhere, free). +snapshot: + .venv/Scripts/python scripts/make_demo_fixtures.py + cd console && NEXT_PUBLIC_DEMO=1 npx next build + +# Mint a per-merchant JWT (default TTL 24h): make merchant-token MERCHANT_ID=mcht_00001 +merchant-token: + .venv/Scripts/python -m sentinel.merchant_token $(MERCHANT_ID) $(TTL) + +# Train the shadow challenger on train-split features (writes evaluation/challenger.pkl). +challenger: + .venv/Scripts/python -m sentinel.challenger_train + +# Load test: measured throughput and latency of the verdict pipeline. +loadtest: + .venv/Scripts/python -m sentinel.loadtest + # Calibrate weights/thresholds on train+calibration splits (writes evaluation/model_config.json). calibrate: .venv/Scripts/python -m sentinel.calibrate diff --git a/README.md b/README.md index 10b3cc8..6f53ba2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Fraud rings in Indian digital payments do not invent new identities, they **recycle** them: the same device, VPA, and phone hit merchant after merchant until each one individually blocks them. Every merchant sees a clean first-time customer; the fraud only exists *between* merchants. Sentinel builds the identity link graph across the whole population, scores every payment event against it deterministically, and hands the analyst an evidence bundle, not a black-box number. -> Razorpay AI Buildathon, Track 02 (AI Risk Manager). The system recommends, never acts: no autonomous blocking, no money movement, strictly defense-only. +> Designed in response to a public fraud-detection problem statement on cross-merchant identity reuse. The system recommends, never acts: no autonomous blocking, no money movement, strictly defense-only. ## Measured results (held-out test set, seed 42, single pass) @@ -63,6 +63,10 @@ make evaluate # calibrates on first run, then: held-out metrics + report make backfill # seed 1,000 verdicts + top-20 LLM narratives (bounded spend) make serve # API on http://localhost:8000 (OpenAPI docs at /docs) make console-setup && make console # analyst console on http://localhost:3000 +docker compose up --build # the whole stack in containers (CI-verified) +make challenger # train the shadow challenger; agreement lands in the report +make loadtest # measured throughput/latency of the verdict pipeline +make snapshot # zero-backend static demo into console/out/ (host anywhere) ``` AWS credentials come only from the default credential chain (needed for the Bedrock narrative step and `make models`); everything else runs offline. @@ -118,6 +122,16 @@ The queue triages three bands (BLOCK_REC / REVIEW / ALLOW, shown above), every v Two-key model throughout: a standard key for analyst views and a separate admin key for anything exposing raw identities, with every privileged action written to the audit store under a scope label (never the key value). +## Production hardening (v2) + +- **Champion/challenger shadow scoring**: a GBDT challenger records its opinion next to every verdict and never decides; held-out agreement 96.45 percent, promotion gated by four written criteria ([docs/14](docs/14-champion-challenger.md)). +- **Per-merchant JWT authn**: HS256 bearer tokens; a merchant can ingest and read only its own traffic (403 on cross-merchant ingest), enforcing federated privacy at the identity layer. +- **Observability**: `/metrics` in Prometheus text format (request counters, verdict bands, latency histograms, warm-up-aware score-drift gauges) plus `X-Request-Id` correlation on every response. +- **Containers and engines**: multi-stage Dockerfiles and compose for the full stack, built and booted by CI on every push; the same store code runs unchanged on Postgres 16 in a CI service container (SQLite pragmas gated by dialect). +- **Measured capacity**: 178 events/s per worker at p95 15.2 ms; the threaded probe proves process sharding, not threads, is the scale path. The [productionization RFC](docs/15-productionization-rfc.md) derives the 10k events/s plan from these numbers. +- **Zero-backend demo snapshot**: `make snapshot` bakes the whole console into static files, labeled as a recorded run, hostable on any bucket for free. +- Also in /docs: [threat model](docs/16-threat-model.md), [operations runbook](docs/17-runbook.md), and [six ADRs](docs/18-adrs.md). + ## Security and defense-only posture - No real PII anywhere: all data is synthetic by construction, and labels never cross the serving boundary. @@ -131,17 +145,18 @@ The full log lives in [`docs/what-broke.md`](docs/what-broke.md), twenty genuine ## Documentation and repository -The complete design suite (problem and loss model, architecture, data design, ML and evaluation protocol with the rupee cost model, API specification, security and DPDP/PCI alignment, and the phase-gated roadmap) is in [`docs/`](docs/README.md). +The complete design suite (problem and loss model, architecture, data design, ML and evaluation protocol with the rupee cost model, API specification, security and DPDP/PCI alignment, and the phase-gated roadmap) is in [`docs/`](docs/README.md). The full engineering story is told in the deep-dive article: **[Sentinel: Catching Fraud Rings That Cross Merchants](docs/blog/engineering-deep-dive.md)**. ``` src/sentinel/ detection engine: normalization, identity graph, features, - scorer, verdicts, calibration, evaluation, LLM layer, - FastAPI service, SQLite audit store + scorer, verdicts, calibration, evaluation, challenger, + auth, observability, LLM layer, FastAPI service, audit store console/ Next.js analyst console (the watchroom design system) -tests/ 160 tests, 91% coverage, strict mypy, CI-gated -docs/ design suite, what-broke log, screenshots, banner -.github/ CI: lint, strict types, coverage gate, secret scan -scripts/ bounded Bedrock verification, git hooks +tests/ 194 tests, 91% coverage, strict mypy, CI-gated +docs/ design suite, RFC, threat model, runbook, ADRs, what-broke log +.github/ CI: lint, types, coverage, secret scan, docker build, postgres +Dockerfile multi-stage api + console images and compose stack +scripts/ Bedrock verification, demo fixtures, git hooks ``` ## License diff --git a/console/.dockerignore b/console/.dockerignore new file mode 100644 index 0000000..a608450 --- /dev/null +++ b/console/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.next +out diff --git a/console/Dockerfile b/console/Dockerfile new file mode 100644 index 0000000..a0de1ad --- /dev/null +++ b/console/Dockerfile @@ -0,0 +1,28 @@ +# Sentinel analyst console (Next.js standalone). +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +FROM node:22-alpine AS build +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +ARG NEXT_PUBLIC_API_URL=http://localhost:8000 +ARG NEXT_PUBLIC_API_KEY=dev-sentinel-key +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_KEY=$NEXT_PUBLIC_API_KEY +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/console/next.config.ts b/console/next.config.ts index 57b9816..45472af 100644 --- a/console/next.config.ts +++ b/console/next.config.ts @@ -3,6 +3,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { // The console is a local analyst tool; no image optimization needed. images: { unoptimized: true }, + // Standalone for containers; static export for the zero-backend + // demo snapshot (NEXT_PUBLIC_DEMO=1); dev is unaffected by either. + output: process.env.NEXT_PUBLIC_DEMO === "1" ? "export" : "standalone", }; export default nextConfig; diff --git a/console/public/.gitkeep b/console/public/.gitkeep new file mode 100644 index 0000000..4643bee --- /dev/null +++ b/console/public/.gitkeep @@ -0,0 +1 @@ +# Static assets mount for the console container. diff --git a/console/src/app/globals.css b/console/src/app/globals.css index 03976ed..a9b002a 100644 --- a/console/src/app/globals.css +++ b/console/src/app/globals.css @@ -99,6 +99,18 @@ body { animation: row-arrival 1.2s ease-out; } +@keyframes spine-flow { + from { + stroke-dashoffset: 100; + } + to { + stroke-dashoffset: 0; + } +} +.spine-flow { + animation: spine-flow 1.6s linear infinite; +} + @keyframes sweep { from { transform: rotate(0deg); diff --git a/console/src/app/playground/page.tsx b/console/src/app/playground/page.tsx new file mode 100644 index 0000000..d6c1512 --- /dev/null +++ b/console/src/app/playground/page.tsx @@ -0,0 +1,336 @@ +"use client"; + +/* + Playground: the visitor composes their own payment event and watches + the real engine score it. Submit once, then reuse the same device in + a second submission and watch the score jump: the visitor becomes the + fraud ring, which is the product's entire thesis in two clicks. +*/ + +import { useState } from "react"; +import { ClusterGraph, type ClusterData } from "@/components/ClusterGraph"; +import { ReasonChip, ScoreMeter, VerdictBadge } from "@/components/Verdict"; +import { fetchCluster, ingestEvent, useApi, type Verdict } from "@/lib/api"; +import { factorLabel, rupees } from "@/lib/format"; + +const FEATURE_KEYS = [ + "device_identity_ratio", + "cross_merchant_fanout", + "taint_propagation", + "velocity_72h", + "burn_rotate", + "amount_pattern", + "new_identity_burst", +]; + +const NORM_KEYS: Record = { + device_identity_ratio: "n_f1", + cross_merchant_fanout: "n_f2", + taint_propagation: "n_f3", + velocity_72h: "n_f4", + burn_rotate: "n_f5", + amount_pattern: "n_f6", + new_identity_burst: "n_f7", +}; + +interface Draft { + device: string; + phone: string; + vpa: string; + merchant: string; + amountRupees: number; + outcome: string; +} + +const DEFAULT_DRAFT: Draft = { + device: "dev_visitor_01", + phone: "+919800000001", + vpa: "visitor@ybl", + merchant: "mcht_playground_1", + amountRupees: 1499, + outcome: "none", +}; + +function randomish(): Draft { + const n = Math.floor(Math.random() * 9000 + 1000); + return { + device: `dev_visitor_${n}`, + phone: `+9198${String(10000000 + n * 733).slice(0, 8)}`, + vpa: `visitor${n}@ybl`, + merchant: `mcht_playground_${1 + (n % 6)}`, + amountRupees: 199 + (n % 2800), + outcome: "none", + }; +} + +export default function PlaygroundPage() { + const { base, key } = useApi(); + const [draft, setDraft] = useState(DEFAULT_DRAFT); + const [history, setHistory] = useState<{ draft: Draft; verdict: Verdict | null; note?: string }[]>([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const submit = async (reuseDevice: boolean) => { + setBusy(true); + setError(null); + const eventDraft = reuseDevice && history.length + ? { ...draft, device: history[0].draft.device, phone: history[0].draft.phone, vpa: history[0].draft.vpa } + : draft; + const index = history.length + 1; + const payload = { + event_id: `pg-${Date.now().toString(36)}-${index}`, + merchant_id: eventDraft.merchant, + customer_id: `cust_visitor_${Math.floor(Math.random() * 900000 + 100000)}`, + amount_paise: Math.round(eventDraft.amountRupees * 100), + currency: "INR", + upi_vpa: eventDraft.vpa.toLowerCase(), + phone: eventDraft.phone, + device_id: eventDraft.device, + email: null, + ip: "103.21.58.7", + ts: new Date().toISOString(), + payment_method: "upi", + prior_outcome: + eventDraft.outcome === "none" + ? null + : eventDraft.outcome === "chargeback" + ? "chargeback" + : "refund_abuse", + }; + try { + const result = await ingestEvent(base, key, payload); + const verdictBody = result.status === 409 ? result.body.verdict : result.body; + setHistory((prev) => [{ draft: eventDraft, verdict: verdictBody ?? null }, ...prev]); + } catch (err) { + setError(err instanceof Error ? err.message : "request failed"); + } finally { + setBusy(false); + } + }; + + const latest = history[0]; + const first = history[history.length - 1]; + const jump = + history.length >= 2 && latest?.verdict && first?.verdict + ? latest.verdict.score - first.verdict.score + : null; + const [clusterCustomer, setClusterCustomer] = useState(null); + const [cluster, setCluster] = useState(null); + + const openCluster = async (customerId: string | null | undefined) => { + if (!customerId) return; + setClusterCustomer(customerId); + try { + setCluster(await fetchCluster(base, key, customerId)); + } catch { + setCluster(null); + } + }; + + return ( +
+
+
+

Try your own event

+

+ compose a payment and submit it to the real engine. then submit again reusing the + same device: watch your score jump as the ring builds +

+
+ +
+ {( + [ + ["device", "device id"], + ["phone", "phone"], + ["vpa", "upi vpa"], + ["merchant", "merchant id"], + ] as const + ).map(([field, label]) => ( + + ))} +
+ + +
+
+ + +
+ {history.length >= 1 && ( + + )} + {error &&

{error}

} +
+ + {jump !== null && ( +
+

the ring effect, live

+

+ your first event scored{" "} + {first?.verdict?.score}. this one:{" "} + {latest?.verdict?.score}{" "} + + ({jump >= 0 ? "+" : ""} + {jump}) + {" "} + because the graph now knows your device. +

+
+ )} + + {history.length > 0 && ( +
    + {[...history].reverse().map((entry, reverseIndex) => { + const index = history.length - 1 - reverseIndex; + return ( +
  • + #{index + 1} + + {entry.draft.device} · {entry.draft.merchant} · {rupees(entry.draft.amountRupees * 100)} + + {entry.verdict ? ( + + ) : ( + failed + )} +
  • + ); + })} +
+ )} +
+ +
+ {latest && latest.verdict ? (() => { + const v = latest.verdict; + return ( + <> +
+
+
+ + + your event, scored live + +
+
+ +
+
+ {latest.verdict.reason_codes.length > 0 && ( +
+ {latest.verdict.reason_codes.map((code) => ( + + ))} +
+ )} +
+ +
+ signal decomposition +
    + {FEATURE_KEYS.map((feature) => { + const normalized = v.features[NORM_KEYS[feature]] ?? 0; + return ( +
  • + {factorLabel(feature)} + + + + + {v.features[feature]} + +
  • + ); + })} +
+
+ + {cluster ? ( + + ) : ( + clusterCustomer && ( + + ) + )} + + ); + })() : ( +
+

Become the fraud ring.

+

+ score an event, then reuse its identity and watch the graph remember. every + submission runs the real deterministic engine: no simulation +

+
+ )} +
+
+ ); +} diff --git a/console/src/app/replay/page.tsx b/console/src/app/replay/page.tsx index de6be5a..b969f91 100644 --- a/console/src/app/replay/page.tsx +++ b/console/src/app/replay/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { ScoreNumber, VerdictBadge } from "@/components/Verdict"; +import { DEMO_MODE, demoFeed } from "@/lib/demo"; import { fetchScenario, ingestEvent, useApi, type Scenario } from "@/lib/api"; import { rupees, shortId } from "@/lib/format"; @@ -40,6 +41,27 @@ export default function ReplayPage() { setRunning(true); setFeed([]); const token = ++runToken.current; + if (DEMO_MODE) { + const feed = demoFeed(); + for (const row of feed) { + if (runToken.current !== token) break; + setFeed((prev) => [ + ...prev, + { + index: row.index, + merchant: row.merchant, + customer: row.customer, + amount: row.amount_paise, + score: row.score, + verdict: row.verdict, + duplicate: row.duplicate, + }, + ]); + await new Promise((resolve) => setTimeout(resolve, STEP_MS)); + } + if (runToken.current === token) setRunning(false); + return; + } /* Each run ingests a fresh copy of the scenario: event ids carry a per-run suffix so idempotency never swallows the demo. When the @@ -158,6 +180,9 @@ export default function ReplayPage() { ))} + {DEMO_MODE && ( +

static snapshot: recorded run, no backend attached

+ )}

watch the score climb as the ring’s shared entities accumulate fan-out and taint · a returning ring is flagged even faster the second time diff --git a/console/src/app/system/page.tsx b/console/src/app/system/page.tsx new file mode 100644 index 0000000..004226b --- /dev/null +++ b/console/src/app/system/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +/* + The System view: the repo's depth, rendered for people who don't + read files. Everything here is served from the real documents + (what-broke log, ADRs, threat model) and the real evaluation + artifacts, via GET /v1/system. No authored marketing text. +*/ + +import { useEffect, useState } from "react"; +import { ArchitectureDiagram } from "@/components/ArchitectureDiagram"; +import { EvalProtocolDiagram } from "@/components/EvalProtocolDiagram"; +import { useApi } from "@/lib/api"; + +interface SystemData { + what_broke: { date: string; phase: string; broke: string; cause: string; fix: string }[]; + decisions: { id: string; title: string; decision: string }[]; + threats: { number: string; class: string; threat: string; control: string; residual: string }[]; + disclosures: { title: string; detail: string }[]; + headline: { precision?: number; recall?: number; f1?: number; rings_caught?: number; rings_total?: number }; + components: { name: string; detail: string }[]; +} + +const TABS = ["System", "Honesty", "Decisions", "Security", "Build log"] as const; +type Tab = (typeof TABS)[number]; + +function Panel({ + children, + title, + aside, +}: { + children: React.ReactNode; + title: string; + aside?: string; +}) { + return ( +

+
+

{title}

+ {aside && {aside}} +
+ {children} +
+ ); +} + +export default function SystemPage() { + const { base, key } = useApi(); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [tab, setTab] = useState("System"); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const response = await fetch(`${base}/v1/system`, { headers: { "X-API-Key": key } }); + if (!response.ok) throw new Error(`${response.status}`); + setData((await response.json()) as SystemData); + } catch (err) { + setError(err instanceof Error ? err.message : "failed"); + } + })(); + return () => { + cancelled = true; + }; + }, [base, key]); + + if (error) { + return ( +
+

system data unavailable

+

+ The About data comes from the repository documents. Run the API from the repo root + (where /docs lives) with make serve. +

+
+ ); + } + + if (!data) { + return
loading system…
; + } + + return ( +
+
+

The system, honestly

+

+ everything on this page is rendered from the repository’s real documents and + evaluation artifacts · nothing authored for show +

+
+ + + +
+ {tab === "System" && ( + <> +
+ {( + [ + ["precision", data.headline.precision?.toFixed(3)], + ["recall", data.headline.recall?.toFixed(3)], + ["f1", data.headline.f1?.toFixed(3)], + [ + "rings caught", + `${data.headline.rings_caught ?? 0}/${data.headline.rings_total ?? 0}`, + ], + ] as const + ).map(([label, value]) => ( + +

{value}

+
+ ))} +
+ + + +
    + {data.components.map((component, index) => ( +
  1. + {String(index + 1).padStart(2, "0")} + {component.name} + {component.detail} +
  2. + ))} +
+
+ +

+ Open the Playground tab: compose a payment event, + score it, then reuse the same device and watch your score climb as the graph + remembers you. The replay tab runs a recorded fraud ring across six merchants. +

+
+ + )} + + {tab === "Honesty" && ( + <> + +
+ {data.disclosures.map((disclosure) => ( +
+

{disclosure.title}

+

{disclosure.detail}

+
+ ))} +
+
+ +
    + {data.what_broke.map((entry, index) => ( +
  • +
    + {entry.date} + phase {entry.phase} +
    +

    {entry.broke}

    +

    + cause + {entry.cause} +

    +

    + fix + {entry.fix} +

    +
  • + ))} +
+
+ + )} + + {tab === "Decisions" && ( + +
    + {data.decisions.map((adr) => ( +
  • +

    {adr.id}

    +

    {adr.title}

    +

    {adr.decision}

    +
  • + ))} +
+
+ )} + + {tab === "Security" && ( + +
    + {data.threats.map((threat) => ( +
  • +
    + + {threat.class}: {threat.threat} + + #{threat.number} +
    +

    + control + {threat.control} +

    +
  • + ))} +
+
+ )} + + {tab === "Build log" && ( + +
    + {[ + ["docs/01-04", "problem, PRD, architecture, data design"], + ["docs/05", "evaluation protocol: features, metrics, rupee FP-cost model"], + ["docs/06-07", "API specification; security, DPDP/PCI, defense-only analysis"], + ["docs/08-13", "tech decisions, testing, risks, roadmap, pitch, glossary"], + ["docs/14", "champion/challenger shadow model and promotion criteria"], + ["docs/15", "productionization RFC from measured load tests"], + ["docs/16-18", "threat model, operations runbook, six ADRs"], + ["docs/19", "all-AWS deployment kit"], + ["docs/what-broke.md", "every genuine failure, logged in real time"], + ["evaluation/report.md", "the full held-out evaluation report"], + ].map(([path, description]) => ( +
  • + {path} + {description} +
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/console/src/components/ArchitectureDiagram.tsx b/console/src/components/ArchitectureDiagram.tsx new file mode 100644 index 0000000..cfd75e5 --- /dev/null +++ b/console/src/components/ArchitectureDiagram.tsx @@ -0,0 +1,179 @@ +"use client"; + +/* + The pipeline, drawn by hand in the watchroom style: event in on the + left, verdict out on the right, the deterministic spine in the middle, + the LLM hanging off it asynchronously (it never scores), and the audit + store underneath. Same visual language as the cluster graph. +*/ + +import { useEffect, useState } from "react"; + +const INK = "#15171c"; +const TEXT = "#e8e3d5"; +const MUTED = "#8b8574"; +const FAINT = "#565246"; +const AMBER = "#e8a33d"; +const ALLOW = "#4cc38a"; +const BLOCK = "#e5484d"; +const LINE = "#31363f"; + +const W = 1100; +const H = 320; + +interface Box { + x: number; + y: number; + w: number; + h: number; + label: string; + sub: string; + color: string; +} + +const SPINE_Y = 90; +const SPINE_H = 62; +const BOX_GAP = 55; + +const BOXES: Box[] = [ + { x: 30, y: SPINE_Y, w: 150, h: SPINE_H, label: "Event", sub: "payment in", color: "#8fb8d8" }, + { x: 30 + 205, y: SPINE_Y, w: 170, h: SPINE_H, label: "Normalize", sub: "E.164 / VPA", color: "#8fb8d8" }, + { x: 30 + 430, y: SPINE_Y, w: 170, h: SPINE_H, label: "Identity graph", sub: "taint / fanout", color: "#6fbfa8" }, + { x: 30 + 655, y: SPINE_Y, w: 150, h: SPINE_H, label: "Scorer", sub: "7 features", color: AMBER }, + { x: 30 + 860, y: SPINE_Y, w: 180, h: SPINE_H, label: "Verdict engine", sub: "ALLOW / REV / BLK", color: TEXT }, +]; + +const LLM = { x: 460, y: 230, w: 160, h: 56 }; +const AUDIT = { x: 890, y: 230, w: 150, h: 56 }; + +export function ArchitectureDiagram() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + const timer = setTimeout(() => setMounted(true), 100); + return () => clearTimeout(timer); + }, []); + + const enter = (delay: number) => ({ + opacity: mounted ? 1 : 0, + transform: mounted ? "translateY(0)" : "translateY(8px)", + transition: `opacity 0.5s ease ${delay}ms, transform 0.5s ease ${delay}ms`, + }); + + const spineY = SPINE_Y + SPINE_H / 2; + + return ( +
+ + + + + + + + + + + + + + + {/* arrows between spine boxes */} + {BOXES.slice(0, -1).map((box, index) => { + const next = BOXES[index + 1]; + const y = spineY; + return ( + + + + + ); + })} + + {/* spine boxes */} + {BOXES.map((box, index) => ( + + + + {box.label} + + + {box.sub} + + + ))} + + {/* verdict triad above the verdict engine */} + {( + [ + ["ALLOW", ALLOW, 895], + ["REVIEW", AMBER, 970], + ["BLOCK", BLOCK, 1040], + ] as const + ).map(([label, color, x], index) => ( + + + + {label} + + + ))} + + + {/* LLM branch: dashed amber, hanging off scorer */} + + + + LLM narrative + + + Bedrock / async + + + + never scores + + + + {/* audit store beneath verdict engine */} + + + + Audit store + + + append-only + + + + + {/* bottom annotations: two clean blocks */} + + + failure path: store down = spool + 503 REVIEW / LLM down = SKIPPED / never crash + + + security: rate limited per IP / admin routes 404 on public demo / every verdict audited + + + +
+ the deterministic spine in the middle · the LLM hangs off it asynchronously and never scores · every verdict lands in the audit store +
+
+ ); +} diff --git a/console/src/components/CommandBar.tsx b/console/src/components/CommandBar.tsx index 4a4b26b..316eeca 100644 --- a/console/src/components/CommandBar.tsx +++ b/console/src/components/CommandBar.tsx @@ -2,11 +2,14 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { DEMO_MODE } from "@/lib/demo"; import { LogoMark, Wordmark } from "@/components/Logo"; import { useApi } from "@/lib/api"; const NAV = [ { href: "/", label: "Queue" }, + { href: "/playground", label: "Playground" }, + { href: "/system", label: "System" }, { href: "/metrics", label: "Evaluation" }, { href: "/replay", label: "Replay" }, ]; @@ -42,7 +45,9 @@ export function CommandBar() {
- {base.replace(/^https?:\/\//, "")} + + {DEMO_MODE ? "static snapshot" : base.replace(/^https?:\/\//, "")} + diff --git a/console/src/components/EvalProtocolDiagram.tsx b/console/src/components/EvalProtocolDiagram.tsx new file mode 100644 index 0000000..ac778d4 --- /dev/null +++ b/console/src/components/EvalProtocolDiagram.tsx @@ -0,0 +1,150 @@ +"use client"; + +/* + The evaluation protocol, drawn clean: one dataset fans out into three + ring-stratified splits, weights freeze under train and thresholds + lock under calibration, and the test box arrows once into the + held-out verdicts. Every string is measured to fit its box. +*/ + +import { useEffect, useState } from "react"; + +const TEXT = "#e8e3d5"; +const MUTED = "#8b8574"; +const LINE = "#31363f"; +const TRAIN = "#6fbfa8"; +const CAL = "#d8b36a"; +const TEST = "#e5484d"; + +const W = 1000; +const H = 230; +const SPLIT_Y = 76; +const SPLIT_H = 64; +const SPLIT_W = 150; + +export function EvalProtocolDiagram() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + const timer = setTimeout(() => setMounted(true), 150); + return () => clearTimeout(timer); + }, []); + + const enter = (delay: number) => ({ + opacity: mounted ? 1 : 0, + transform: mounted ? "translateY(0)" : "translateY(8px)", + transition: `opacity 0.5s ease ${delay}ms, transform 0.5s ease ${delay}ms`, + }); + + const midY = SPLIT_Y + SPLIT_H / 2; + const splits = [ + { label: "TRAIN 60%", color: TRAIN, sub: "weights fit here", x: 240 }, + { label: "CALIB 20%", color: CAL, sub: "thresholds locked", x: 424 }, + { label: "TEST 20%", color: TEST, sub: "touched once", x: 608 }, + ]; + const freezes = [ + { line1: "weights frozen", line2: "published in code", x: 240, color: TRAIN }, + { line1: "thresholds locked", line2: "42 / 49", x: 424, color: CAL }, + ]; + + return ( +
+ + + + + + + + + + + + + + + {/* dataset */} + + + + 1,000 events + + + seed 42 · 100 fraud + + + + {/* fan-out curves */} + {splits.map((split, index) => ( + + ))} + + {/* three splits, one row */} + {splits.map((split, index) => ( + + + + {split.label} + + + {split.sub} + + + ))} + + {/* freeze boxes directly beneath their splits */} + {freezes.map((freeze, index) => ( + + + + + {freeze.line1} + + + {freeze.line2} + + + ))} + + {/* test flows once into held-out verdicts */} + + + + + held-out verdicts + + + P 0.833 / R 0.882 / 2 of 2 + + + +
+ order of operations: weights on train, thresholds on calibration, one single pass on test · ring-stratified so no identity spans splits · peeking forfeits the protocol +
+
+ ); +} diff --git a/console/src/components/EvidencePanel.tsx b/console/src/components/EvidencePanel.tsx index 4e43764..0f4a7fb 100644 --- a/console/src/components/EvidencePanel.tsx +++ b/console/src/components/EvidencePanel.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { ClusterGraph, type ClusterData } from "@/components/ClusterGraph"; import { ReasonChip, ScoreMeter, VerdictBadge } from "@/components/Verdict"; -import { fetchCluster, ingestEvent, useApi, type Verdict } from "@/lib/api"; +import { explainLive, fetchCluster, ingestEvent, useApi, type Verdict } from "@/lib/api"; import { factorLabel, shortId } from "@/lib/format"; const FEATURE_KEYS = [ @@ -26,16 +26,34 @@ const NORM_KEYS: Record = { new_identity_burst: "n_f7", }; -function Narrative({ verdict }: { verdict: Verdict }) { +function Narrative({ + verdict, + onGenerate, + generating, + cap, +}: { + verdict: Verdict; + onGenerate: () => void; + generating: boolean; + cap: string | null; +}) { if (verdict.explanation_status !== "DONE" || !verdict.explanation) { return (
analyst narrative

- {verdict.explanation_status === "PENDING" - ? "Narrative generating (async LLM pass; run make backfill)." - : "Narrative unavailable for this verdict."} + {verdict.explanation_status === "CAP_REACHED" + ? "Daily live-generation cap reached; stored narrative shown." + : "No narrative yet. Generate one live with the real model."}

+ + {cap &&

{cap}

}
); } @@ -77,6 +95,29 @@ function Narrative({ verdict }: { verdict: Verdict }) { export function EvidencePanel({ verdict, reviewAt, blockAt }: { verdict: Verdict; reviewAt: number; blockAt: number }) { const { base, key } = useApi(); const [cluster, setCluster] = useState(null); + const [generating, setGenerating] = useState(false); + const [cap, setCap] = useState(null); + const [live, setLive] = useState(null); + const shown = live ?? verdict; + + const generate = async () => { + setGenerating(true); + try { + const result = await explainLive(base, key, shown.event_id); + setLive({ + ...shown, + explanation: result.explanation, + explanation_status: result.explanation_status as Verdict["explanation_status"], + }); + setCap( + `live generation ${result.cap.used}/${result.cap.cap} today (UTC)` + ); + } catch (err) { + setCap(err instanceof Error ? err.message : "generation failed"); + } finally { + setGenerating(false); + } + }; useEffect(() => { let cancelled = false; @@ -175,7 +216,7 @@ export function EvidencePanel({ verdict, reviewAt, blockAt }: { verdict: Verdict cluster unavailable
)} - + diff --git a/console/src/demo/clusters.json b/console/src/demo/clusters.json new file mode 100644 index 0000000..391098a --- /dev/null +++ b/console/src/demo/clusters.json @@ -0,0 +1,2756 @@ +{ + "cust_ce923c": { + "customer_id": "cust_ce923c", + "nodes": [ + { + "type": "customer", + "id": "cust_ce923c", + "taint": 0.36 + }, + { + "type": "email", + "id": "aarti.yash@gmail.com", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_41ee37", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_b7378b", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_dcfc81", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00032", + "taint": 0.0 + }, + { + "type": "merchant", + "id": "mcht_00015", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_1258b0", + "taint": 1.0 + }, + { + "type": "upi", + "id": "karan.1813@yapl", + "taint": 0.6 + }, + { + "type": "upi", + "id": "rajat2726@ibl", + "taint": 0.6 + }, + { + "type": "phone", + "id": "+9188XXXX7272", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00030", + "taint": 0.0 + }, + { + "type": "merchant", + "id": "mcht_00017", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_0ad023", + "taint": 0.36 + }, + { + "type": "phone", + "id": "+9167XXXX4027", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_752832", + "taint": 1.0 + }, + { + "type": "customer", + "id": "cust_55e64a", + "taint": 0.36 + }, + { + "type": "email", + "id": "aarti.897@gmail.com", + "taint": 0.6 + }, + { + "type": "device", + "id": "dev_b7cfe871", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00007", + "taint": 0.0 + }, + { + "type": "upi", + "id": "nikita_3930@okicici", + "taint": 0.216 + }, + { + "type": "merchant", + "id": "mcht_00045", + "taint": 0.0 + }, + { + "type": "email", + "id": "aartiyash46@gmail.com", + "taint": 0.216 + } + ], + "edges": [ + { + "source": "customer:cust_ce923c", + "target": "merchant:mcht_00007" + }, + { + "source": "customer:cust_ce923c", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_ce923c", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_ce923c", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_ce923c", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_41ee37", + "target": "merchant:mcht_00030" + }, + { + "source": "customer:cust_41ee37", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_41ee37", + "target": "phone:+918825417272" + }, + { + "source": "customer:cust_41ee37", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_41ee37", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_b7378b", + "target": "merchant:mcht_00017" + }, + { + "source": "customer:cust_b7378b", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_b7378b", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_b7378b", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_b7378b", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_dcfc81", + "target": "merchant:mcht_00045" + }, + { + "source": "customer:cust_dcfc81", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_dcfc81", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_dcfc81", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_dcfc81", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_1258b0", + "target": "merchant:mcht_00017" + }, + { + "source": "customer:cust_1258b0", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_1258b0", + "target": "phone:+918825417272" + }, + { + "source": "customer:cust_1258b0", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_1258b0", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_0ad023", + "target": "merchant:mcht_00032" + }, + { + "source": "customer:cust_0ad023", + "target": "upi:nikita_3930@okicici" + }, + { + "source": "customer:cust_0ad023", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_0ad023", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_0ad023", + "target": "email:aartiyash46@gmail.com" + }, + { + "source": "customer:cust_752832", + "target": "merchant:mcht_00015" + }, + { + "source": "customer:cust_752832", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_752832", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_752832", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_752832", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_55e64a", + "target": "merchant:mcht_00032" + }, + { + "source": "customer:cust_55e64a", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_55e64a", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_55e64a", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_55e64a", + "target": "email:aartiyash46@gmail.com" + } + ], + "truncated": false + }, + "cust_1258b0": { + "customer_id": "cust_1258b0", + "nodes": [ + { + "type": "customer", + "id": "cust_ce923c", + "taint": 0.36 + }, + { + "type": "email", + "id": "aarti.yash@gmail.com", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_41ee37", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_b7378b", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_dcfc81", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00032", + "taint": 0.0 + }, + { + "type": "merchant", + "id": "mcht_00015", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_1258b0", + "taint": 1.0 + }, + { + "type": "merchant", + "id": "mcht_00030", + "taint": 0.0 + }, + { + "type": "upi", + "id": "rajat2726@ibl", + "taint": 0.6 + }, + { + "type": "phone", + "id": "+9188XXXX7272", + "taint": 0.6 + }, + { + "type": "upi", + "id": "karan.1813@yapl", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00017", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_0ad023", + "taint": 0.36 + }, + { + "type": "phone", + "id": "+9167XXXX4027", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_752832", + "taint": 1.0 + }, + { + "type": "customer", + "id": "cust_55e64a", + "taint": 0.36 + }, + { + "type": "email", + "id": "aarti.897@gmail.com", + "taint": 0.6 + }, + { + "type": "device", + "id": "dev_b7cfe871", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00007", + "taint": 0.0 + }, + { + "type": "upi", + "id": "nikita_3930@okicici", + "taint": 0.216 + }, + { + "type": "merchant", + "id": "mcht_00045", + "taint": 0.0 + }, + { + "type": "email", + "id": "aartiyash46@gmail.com", + "taint": 0.216 + } + ], + "edges": [ + { + "source": "customer:cust_ce923c", + "target": "merchant:mcht_00007" + }, + { + "source": "customer:cust_ce923c", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_ce923c", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_ce923c", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_ce923c", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_41ee37", + "target": "merchant:mcht_00030" + }, + { + "source": "customer:cust_41ee37", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_41ee37", + "target": "phone:+918825417272" + }, + { + "source": "customer:cust_41ee37", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_41ee37", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_b7378b", + "target": "merchant:mcht_00017" + }, + { + "source": "customer:cust_b7378b", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_b7378b", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_b7378b", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_b7378b", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_dcfc81", + "target": "merchant:mcht_00045" + }, + { + "source": "customer:cust_dcfc81", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_dcfc81", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_dcfc81", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_dcfc81", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_1258b0", + "target": "merchant:mcht_00017" + }, + { + "source": "customer:cust_1258b0", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_1258b0", + "target": "phone:+918825417272" + }, + { + "source": "customer:cust_1258b0", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_1258b0", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_0ad023", + "target": "merchant:mcht_00032" + }, + { + "source": "customer:cust_0ad023", + "target": "upi:nikita_3930@okicici" + }, + { + "source": "customer:cust_0ad023", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_0ad023", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_0ad023", + "target": "email:aartiyash46@gmail.com" + }, + { + "source": "customer:cust_752832", + "target": "merchant:mcht_00015" + }, + { + "source": "customer:cust_752832", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_752832", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_752832", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_752832", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_55e64a", + "target": "merchant:mcht_00032" + }, + { + "source": "customer:cust_55e64a", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_55e64a", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_55e64a", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_55e64a", + "target": "email:aartiyash46@gmail.com" + } + ], + "truncated": false + }, + "cust_0ad023": { + "customer_id": "cust_0ad023", + "nodes": [ + { + "type": "customer", + "id": "cust_ce923c", + "taint": 0.36 + }, + { + "type": "email", + "id": "aarti.yash@gmail.com", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_41ee37", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_b7378b", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_dcfc81", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00032", + "taint": 0.0 + }, + { + "type": "merchant", + "id": "mcht_00015", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_1258b0", + "taint": 1.0 + }, + { + "type": "upi", + "id": "karan.1813@yapl", + "taint": 0.6 + }, + { + "type": "upi", + "id": "rajat2726@ibl", + "taint": 0.6 + }, + { + "type": "phone", + "id": "+9188XXXX7272", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00030", + "taint": 0.0 + }, + { + "type": "merchant", + "id": "mcht_00017", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_0ad023", + "taint": 0.36 + }, + { + "type": "phone", + "id": "+9167XXXX4027", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_752832", + "taint": 1.0 + }, + { + "type": "customer", + "id": "cust_55e64a", + "taint": 0.36 + }, + { + "type": "email", + "id": "aarti.897@gmail.com", + "taint": 0.6 + }, + { + "type": "device", + "id": "dev_b7cfe871", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00007", + "taint": 0.0 + }, + { + "type": "upi", + "id": "nikita_3930@okicici", + "taint": 0.216 + }, + { + "type": "merchant", + "id": "mcht_00045", + "taint": 0.0 + }, + { + "type": "email", + "id": "aartiyash46@gmail.com", + "taint": 0.216 + } + ], + "edges": [ + { + "source": "customer:cust_ce923c", + "target": "merchant:mcht_00007" + }, + { + "source": "customer:cust_ce923c", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_ce923c", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_ce923c", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_ce923c", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_41ee37", + "target": "merchant:mcht_00030" + }, + { + "source": "customer:cust_41ee37", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_41ee37", + "target": "phone:+918825417272" + }, + { + "source": "customer:cust_41ee37", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_41ee37", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_b7378b", + "target": "merchant:mcht_00017" + }, + { + "source": "customer:cust_b7378b", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_b7378b", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_b7378b", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_b7378b", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_dcfc81", + "target": "merchant:mcht_00045" + }, + { + "source": "customer:cust_dcfc81", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_dcfc81", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_dcfc81", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_dcfc81", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_1258b0", + "target": "merchant:mcht_00017" + }, + { + "source": "customer:cust_1258b0", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_1258b0", + "target": "phone:+918825417272" + }, + { + "source": "customer:cust_1258b0", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_1258b0", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_0ad023", + "target": "merchant:mcht_00032" + }, + { + "source": "customer:cust_0ad023", + "target": "upi:nikita_3930@okicici" + }, + { + "source": "customer:cust_0ad023", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_0ad023", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_0ad023", + "target": "email:aartiyash46@gmail.com" + }, + { + "source": "customer:cust_752832", + "target": "merchant:mcht_00015" + }, + { + "source": "customer:cust_752832", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_752832", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_752832", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_752832", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_55e64a", + "target": "merchant:mcht_00032" + }, + { + "source": "customer:cust_55e64a", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_55e64a", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_55e64a", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_55e64a", + "target": "email:aartiyash46@gmail.com" + } + ], + "truncated": false + }, + "cust_80b372": { + "customer_id": "cust_80b372", + "nodes": [ + { + "type": "upi", + "id": "priya_6791@okaxis", + "taint": 0.6 + }, + { + "type": "upi", + "id": "riya6059@axl", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00043", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_76f685", + "taint": 0.36 + }, + { + "type": "device", + "id": "dev_3a439f3f", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00036", + "taint": 0.0 + }, + { + "type": "merchant", + "id": "mcht_00040", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_de5b28", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00028", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_e79449", + "taint": 1.0 + }, + { + "type": "customer", + "id": "cust_3616cf", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00037", + "taint": 0.0 + }, + { + "type": "upi", + "id": "karan_6092@paytm", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_3356bd", + "taint": 0.36 + }, + { + "type": "email", + "id": "varun.250@gmail.com", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_2148dd", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_80b372", + "taint": 1.0 + }, + { + "type": "email", + "id": "varunmeera12@gmail.com", + "taint": 0.6 + }, + { + "type": "device", + "id": "dev_23f76327", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_1610b8", + "taint": 1.0 + }, + { + "type": "phone", + "id": "+9179XXXX7079", + "taint": 0.6 + }, + { + "type": "phone", + "id": "+9176XXXX1740", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_c36876", + "taint": 0.36 + }, + { + "type": "email", + "id": "varun.meera@gmail.com", + "taint": 0.216 + }, + { + "type": "upi", + "id": "sonal1052@okhdfcbank", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00027", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_b47ae3", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_4a4cca", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_9b0067", + "taint": 0.36 + } + ], + "edges": [ + { + "source": "customer:cust_76f685", + "target": "merchant:mcht_00027" + }, + { + "source": "customer:cust_76f685", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_76f685", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_76f685", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_76f685", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_de5b28", + "target": "merchant:mcht_00037" + }, + { + "source": "customer:cust_de5b28", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_de5b28", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_de5b28", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_de5b28", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_e79449", + "target": "merchant:mcht_00036" + }, + { + "source": "customer:cust_e79449", + "target": "upi:karan_6092@paytm" + }, + { + "source": "customer:cust_e79449", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_e79449", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_e79449", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_3616cf", + "target": "merchant:mcht_00036" + }, + { + "source": "customer:cust_3616cf", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_3616cf", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_3616cf", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_3616cf", + "target": "email:varun.meera@gmail.com" + }, + { + "source": "customer:cust_3356bd", + "target": "merchant:mcht_00040" + }, + { + "source": "customer:cust_3356bd", + "target": "upi:karan_6092@paytm" + }, + { + "source": "customer:cust_3356bd", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_3356bd", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_3356bd", + "target": "email:varun.meera@gmail.com" + }, + { + "source": "customer:cust_2148dd", + "target": "merchant:mcht_00037" + }, + { + "source": "customer:cust_2148dd", + "target": "upi:riya6059@axl" + }, + { + "source": "customer:cust_2148dd", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_2148dd", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_2148dd", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_80b372", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_80b372", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_80b372", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_80b372", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_80b372", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_1610b8", + "target": "merchant:mcht_00043" + }, + { + "source": "customer:cust_1610b8", + "target": "upi:priya_6791@okaxis" + }, + { + "source": "customer:cust_1610b8", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_1610b8", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_1610b8", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_c36876", + "target": "merchant:mcht_00040" + }, + { + "source": "customer:cust_c36876", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_c36876", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_c36876", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_c36876", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_b47ae3", + "target": "merchant:mcht_00043" + }, + { + "source": "customer:cust_b47ae3", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_b47ae3", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_b47ae3", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_b47ae3", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_4a4cca", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_4a4cca", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_4a4cca", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_4a4cca", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_4a4cca", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_9b0067", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_9b0067", + "target": "upi:priya_6791@okaxis" + }, + { + "source": "customer:cust_9b0067", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_9b0067", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_9b0067", + "target": "email:varun.250@gmail.com" + } + ], + "truncated": false + }, + "cust_3616cf": { + "customer_id": "cust_3616cf", + "nodes": [ + { + "type": "upi", + "id": "priya_6791@okaxis", + "taint": 0.6 + }, + { + "type": "upi", + "id": "riya6059@axl", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00043", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_76f685", + "taint": 0.36 + }, + { + "type": "device", + "id": "dev_3a439f3f", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00036", + "taint": 0.0 + }, + { + "type": "merchant", + "id": "mcht_00040", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_de5b28", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00028", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_e79449", + "taint": 1.0 + }, + { + "type": "customer", + "id": "cust_3616cf", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00037", + "taint": 0.0 + }, + { + "type": "upi", + "id": "karan_6092@paytm", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_3356bd", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_2148dd", + "taint": 0.36 + }, + { + "type": "email", + "id": "varun.250@gmail.com", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_80b372", + "taint": 1.0 + }, + { + "type": "email", + "id": "varunmeera12@gmail.com", + "taint": 0.6 + }, + { + "type": "device", + "id": "dev_23f76327", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_1610b8", + "taint": 1.0 + }, + { + "type": "phone", + "id": "+9179XXXX7079", + "taint": 0.6 + }, + { + "type": "phone", + "id": "+9176XXXX1740", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_c36876", + "taint": 0.36 + }, + { + "type": "email", + "id": "varun.meera@gmail.com", + "taint": 0.216 + }, + { + "type": "upi", + "id": "sonal1052@okhdfcbank", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00027", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_b47ae3", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_4a4cca", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_9b0067", + "taint": 0.36 + } + ], + "edges": [ + { + "source": "customer:cust_76f685", + "target": "merchant:mcht_00027" + }, + { + "source": "customer:cust_76f685", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_76f685", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_76f685", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_76f685", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_de5b28", + "target": "merchant:mcht_00037" + }, + { + "source": "customer:cust_de5b28", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_de5b28", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_de5b28", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_de5b28", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_e79449", + "target": "merchant:mcht_00036" + }, + { + "source": "customer:cust_e79449", + "target": "upi:karan_6092@paytm" + }, + { + "source": "customer:cust_e79449", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_e79449", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_e79449", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_3616cf", + "target": "merchant:mcht_00036" + }, + { + "source": "customer:cust_3616cf", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_3616cf", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_3616cf", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_3616cf", + "target": "email:varun.meera@gmail.com" + }, + { + "source": "customer:cust_3356bd", + "target": "merchant:mcht_00040" + }, + { + "source": "customer:cust_3356bd", + "target": "upi:karan_6092@paytm" + }, + { + "source": "customer:cust_3356bd", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_3356bd", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_3356bd", + "target": "email:varun.meera@gmail.com" + }, + { + "source": "customer:cust_2148dd", + "target": "merchant:mcht_00037" + }, + { + "source": "customer:cust_2148dd", + "target": "upi:riya6059@axl" + }, + { + "source": "customer:cust_2148dd", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_2148dd", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_2148dd", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_80b372", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_80b372", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_80b372", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_80b372", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_80b372", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_1610b8", + "target": "merchant:mcht_00043" + }, + { + "source": "customer:cust_1610b8", + "target": "upi:priya_6791@okaxis" + }, + { + "source": "customer:cust_1610b8", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_1610b8", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_1610b8", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_c36876", + "target": "merchant:mcht_00040" + }, + { + "source": "customer:cust_c36876", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_c36876", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_c36876", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_c36876", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_b47ae3", + "target": "merchant:mcht_00043" + }, + { + "source": "customer:cust_b47ae3", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_b47ae3", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_b47ae3", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_b47ae3", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_4a4cca", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_4a4cca", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_4a4cca", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_4a4cca", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_4a4cca", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_9b0067", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_9b0067", + "target": "upi:priya_6791@okaxis" + }, + { + "source": "customer:cust_9b0067", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_9b0067", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_9b0067", + "target": "email:varun.250@gmail.com" + } + ], + "truncated": false + }, + "cust_c36876": { + "customer_id": "cust_c36876", + "nodes": [ + { + "type": "upi", + "id": "priya_6791@okaxis", + "taint": 0.6 + }, + { + "type": "upi", + "id": "riya6059@axl", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00043", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_76f685", + "taint": 0.36 + }, + { + "type": "device", + "id": "dev_3a439f3f", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00040", + "taint": 0.0 + }, + { + "type": "merchant", + "id": "mcht_00036", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_de5b28", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00028", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_e79449", + "taint": 1.0 + }, + { + "type": "customer", + "id": "cust_3616cf", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00037", + "taint": 0.0 + }, + { + "type": "upi", + "id": "karan_6092@paytm", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_3356bd", + "taint": 0.36 + }, + { + "type": "email", + "id": "varun.250@gmail.com", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_2148dd", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_80b372", + "taint": 1.0 + }, + { + "type": "email", + "id": "varunmeera12@gmail.com", + "taint": 0.6 + }, + { + "type": "device", + "id": "dev_23f76327", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_1610b8", + "taint": 1.0 + }, + { + "type": "phone", + "id": "+9179XXXX7079", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_ddb568", + "taint": 1.0 + }, + { + "type": "phone", + "id": "+9176XXXX1740", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_c36876", + "taint": 0.36 + }, + { + "type": "email", + "id": "varun.meera@gmail.com", + "taint": 0.216 + }, + { + "type": "upi", + "id": "sonal1052@okhdfcbank", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00027", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_b47ae3", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_4a4cca", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_9b0067", + "taint": 0.36 + } + ], + "edges": [ + { + "source": "customer:cust_76f685", + "target": "merchant:mcht_00027" + }, + { + "source": "customer:cust_76f685", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_76f685", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_76f685", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_76f685", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_de5b28", + "target": "merchant:mcht_00037" + }, + { + "source": "customer:cust_de5b28", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_de5b28", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_de5b28", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_de5b28", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_e79449", + "target": "merchant:mcht_00036" + }, + { + "source": "customer:cust_e79449", + "target": "upi:karan_6092@paytm" + }, + { + "source": "customer:cust_e79449", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_e79449", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_e79449", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_3616cf", + "target": "merchant:mcht_00036" + }, + { + "source": "customer:cust_3616cf", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_3616cf", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_3616cf", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_3616cf", + "target": "email:varun.meera@gmail.com" + }, + { + "source": "customer:cust_3356bd", + "target": "merchant:mcht_00040" + }, + { + "source": "customer:cust_3356bd", + "target": "upi:karan_6092@paytm" + }, + { + "source": "customer:cust_3356bd", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_3356bd", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_3356bd", + "target": "email:varun.meera@gmail.com" + }, + { + "source": "customer:cust_2148dd", + "target": "merchant:mcht_00037" + }, + { + "source": "customer:cust_2148dd", + "target": "upi:riya6059@axl" + }, + { + "source": "customer:cust_2148dd", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_2148dd", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_2148dd", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_80b372", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_80b372", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_80b372", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_80b372", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_80b372", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_1610b8", + "target": "merchant:mcht_00043" + }, + { + "source": "customer:cust_1610b8", + "target": "upi:priya_6791@okaxis" + }, + { + "source": "customer:cust_1610b8", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_1610b8", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_1610b8", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_ddb568", + "target": "merchant:mcht_00027" + }, + { + "source": "customer:cust_ddb568", + "target": "upi:riya6059@axl" + }, + { + "source": "customer:cust_ddb568", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_ddb568", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_ddb568", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_c36876", + "target": "merchant:mcht_00040" + }, + { + "source": "customer:cust_c36876", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_c36876", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_c36876", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_c36876", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_b47ae3", + "target": "merchant:mcht_00043" + }, + { + "source": "customer:cust_b47ae3", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_b47ae3", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_b47ae3", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_b47ae3", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_4a4cca", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_4a4cca", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_4a4cca", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_4a4cca", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_4a4cca", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_9b0067", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_9b0067", + "target": "upi:priya_6791@okaxis" + }, + { + "source": "customer:cust_9b0067", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_9b0067", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_9b0067", + "target": "email:varun.250@gmail.com" + } + ], + "truncated": false + }, + "cust_dcfc81": { + "customer_id": "cust_dcfc81", + "nodes": [ + { + "type": "customer", + "id": "cust_ce923c", + "taint": 0.36 + }, + { + "type": "email", + "id": "aarti.yash@gmail.com", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_41ee37", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_b7378b", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_dcfc81", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00032", + "taint": 0.0 + }, + { + "type": "merchant", + "id": "mcht_00015", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_1258b0", + "taint": 1.0 + }, + { + "type": "upi", + "id": "karan.1813@yapl", + "taint": 0.6 + }, + { + "type": "upi", + "id": "rajat2726@ibl", + "taint": 0.6 + }, + { + "type": "phone", + "id": "+9188XXXX7272", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00030", + "taint": 0.0 + }, + { + "type": "merchant", + "id": "mcht_00017", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_0ad023", + "taint": 0.36 + }, + { + "type": "phone", + "id": "+9167XXXX4027", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_752832", + "taint": 1.0 + }, + { + "type": "customer", + "id": "cust_55e64a", + "taint": 0.36 + }, + { + "type": "email", + "id": "aarti.897@gmail.com", + "taint": 0.6 + }, + { + "type": "device", + "id": "dev_b7cfe871", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00007", + "taint": 0.0 + }, + { + "type": "upi", + "id": "nikita_3930@okicici", + "taint": 0.216 + }, + { + "type": "merchant", + "id": "mcht_00045", + "taint": 0.0 + }, + { + "type": "email", + "id": "aartiyash46@gmail.com", + "taint": 0.216 + } + ], + "edges": [ + { + "source": "customer:cust_ce923c", + "target": "merchant:mcht_00007" + }, + { + "source": "customer:cust_ce923c", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_ce923c", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_ce923c", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_ce923c", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_41ee37", + "target": "merchant:mcht_00030" + }, + { + "source": "customer:cust_41ee37", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_41ee37", + "target": "phone:+918825417272" + }, + { + "source": "customer:cust_41ee37", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_41ee37", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_b7378b", + "target": "merchant:mcht_00017" + }, + { + "source": "customer:cust_b7378b", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_b7378b", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_b7378b", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_b7378b", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_dcfc81", + "target": "merchant:mcht_00045" + }, + { + "source": "customer:cust_dcfc81", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_dcfc81", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_dcfc81", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_dcfc81", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_1258b0", + "target": "merchant:mcht_00017" + }, + { + "source": "customer:cust_1258b0", + "target": "upi:rajat2726@ibl" + }, + { + "source": "customer:cust_1258b0", + "target": "phone:+918825417272" + }, + { + "source": "customer:cust_1258b0", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_1258b0", + "target": "email:aarti.yash@gmail.com" + }, + { + "source": "customer:cust_0ad023", + "target": "merchant:mcht_00032" + }, + { + "source": "customer:cust_0ad023", + "target": "upi:nikita_3930@okicici" + }, + { + "source": "customer:cust_0ad023", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_0ad023", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_0ad023", + "target": "email:aartiyash46@gmail.com" + }, + { + "source": "customer:cust_752832", + "target": "merchant:mcht_00015" + }, + { + "source": "customer:cust_752832", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_752832", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_752832", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_752832", + "target": "email:aarti.897@gmail.com" + }, + { + "source": "customer:cust_55e64a", + "target": "merchant:mcht_00032" + }, + { + "source": "customer:cust_55e64a", + "target": "upi:karan.1813@yapl" + }, + { + "source": "customer:cust_55e64a", + "target": "phone:+916733854027" + }, + { + "source": "customer:cust_55e64a", + "target": "device:dev_b7cfe871" + }, + { + "source": "customer:cust_55e64a", + "target": "email:aartiyash46@gmail.com" + } + ], + "truncated": false + }, + "cust_76f685": { + "customer_id": "cust_76f685", + "nodes": [ + { + "type": "upi", + "id": "priya_6791@okaxis", + "taint": 0.6 + }, + { + "type": "upi", + "id": "riya6059@axl", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00043", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_76f685", + "taint": 0.36 + }, + { + "type": "device", + "id": "dev_3a439f3f", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00036", + "taint": 0.0 + }, + { + "type": "merchant", + "id": "mcht_00040", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_de5b28", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00028", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_e79449", + "taint": 1.0 + }, + { + "type": "customer", + "id": "cust_3616cf", + "taint": 0.36 + }, + { + "type": "merchant", + "id": "mcht_00037", + "taint": 0.0 + }, + { + "type": "upi", + "id": "karan_6092@paytm", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_3356bd", + "taint": 0.36 + }, + { + "type": "email", + "id": "varun.250@gmail.com", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_2148dd", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_80b372", + "taint": 1.0 + }, + { + "type": "email", + "id": "varunmeera12@gmail.com", + "taint": 0.6 + }, + { + "type": "device", + "id": "dev_23f76327", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_1610b8", + "taint": 1.0 + }, + { + "type": "phone", + "id": "+9179XXXX7079", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_ddb568", + "taint": 1.0 + }, + { + "type": "phone", + "id": "+9176XXXX1740", + "taint": 0.6 + }, + { + "type": "customer", + "id": "cust_c36876", + "taint": 0.36 + }, + { + "type": "email", + "id": "varun.meera@gmail.com", + "taint": 0.216 + }, + { + "type": "upi", + "id": "sonal1052@okhdfcbank", + "taint": 0.6 + }, + { + "type": "merchant", + "id": "mcht_00027", + "taint": 0.0 + }, + { + "type": "customer", + "id": "cust_b47ae3", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_4a4cca", + "taint": 0.36 + }, + { + "type": "customer", + "id": "cust_9b0067", + "taint": 0.36 + } + ], + "edges": [ + { + "source": "customer:cust_76f685", + "target": "merchant:mcht_00027" + }, + { + "source": "customer:cust_76f685", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_76f685", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_76f685", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_76f685", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_de5b28", + "target": "merchant:mcht_00037" + }, + { + "source": "customer:cust_de5b28", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_de5b28", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_de5b28", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_de5b28", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_e79449", + "target": "merchant:mcht_00036" + }, + { + "source": "customer:cust_e79449", + "target": "upi:karan_6092@paytm" + }, + { + "source": "customer:cust_e79449", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_e79449", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_e79449", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_3616cf", + "target": "merchant:mcht_00036" + }, + { + "source": "customer:cust_3616cf", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_3616cf", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_3616cf", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_3616cf", + "target": "email:varun.meera@gmail.com" + }, + { + "source": "customer:cust_3356bd", + "target": "merchant:mcht_00040" + }, + { + "source": "customer:cust_3356bd", + "target": "upi:karan_6092@paytm" + }, + { + "source": "customer:cust_3356bd", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_3356bd", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_3356bd", + "target": "email:varun.meera@gmail.com" + }, + { + "source": "customer:cust_2148dd", + "target": "merchant:mcht_00037" + }, + { + "source": "customer:cust_2148dd", + "target": "upi:riya6059@axl" + }, + { + "source": "customer:cust_2148dd", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_2148dd", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_2148dd", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_80b372", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_80b372", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_80b372", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_80b372", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_80b372", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_1610b8", + "target": "merchant:mcht_00043" + }, + { + "source": "customer:cust_1610b8", + "target": "upi:priya_6791@okaxis" + }, + { + "source": "customer:cust_1610b8", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_1610b8", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_1610b8", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_ddb568", + "target": "merchant:mcht_00027" + }, + { + "source": "customer:cust_ddb568", + "target": "upi:riya6059@axl" + }, + { + "source": "customer:cust_ddb568", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_ddb568", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_ddb568", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_c36876", + "target": "merchant:mcht_00040" + }, + { + "source": "customer:cust_c36876", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_c36876", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_c36876", + "target": "device:dev_3a439f3f" + }, + { + "source": "customer:cust_c36876", + "target": "email:varun.250@gmail.com" + }, + { + "source": "customer:cust_b47ae3", + "target": "merchant:mcht_00043" + }, + { + "source": "customer:cust_b47ae3", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_b47ae3", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_b47ae3", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_b47ae3", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_4a4cca", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_4a4cca", + "target": "upi:sonal1052@okhdfcbank" + }, + { + "source": "customer:cust_4a4cca", + "target": "phone:+917644681740" + }, + { + "source": "customer:cust_4a4cca", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_4a4cca", + "target": "email:varunmeera12@gmail.com" + }, + { + "source": "customer:cust_9b0067", + "target": "merchant:mcht_00028" + }, + { + "source": "customer:cust_9b0067", + "target": "upi:priya_6791@okaxis" + }, + { + "source": "customer:cust_9b0067", + "target": "phone:+917907457079" + }, + { + "source": "customer:cust_9b0067", + "target": "device:dev_23f76327" + }, + { + "source": "customer:cust_9b0067", + "target": "email:varun.250@gmail.com" + } + ], + "truncated": false + } +} \ No newline at end of file diff --git a/console/src/demo/evaluation.json b/console/src/demo/evaluation.json new file mode 100644 index 0000000..2dd8ffb --- /dev/null +++ b/console/src/demo/evaluation.json @@ -0,0 +1,242 @@ +{ + "metrics": { + "baselines": { + "gradient_boosting": { + "f1": 0.9091, + "precision": 0.9375, + "recall": 0.8824 + }, + "logistic_regression": { + "f1": 0.7586, + "precision": 0.9167, + "recall": 0.6471 + } + }, + "calibration_deciles": [ + { + "events": 9, + "fraud": 0, + "fraud_rate": 0.0, + "score_range": "5-7" + }, + { + "events": 18, + "fraud": 0, + "fraud_rate": 0.0, + "score_range": "8-12" + }, + { + "events": 86, + "fraud": 0, + "fraud_rate": 0.0, + "score_range": "25-40" + }, + { + "events": 66, + "fraud": 2, + "fraud_rate": 0.0303, + "score_range": "42-47" + }, + { + "events": 3, + "fraud": 3, + "fraud_rate": 1.0, + "score_range": "49-50" + }, + { + "events": 2, + "fraud": 2, + "fraud_rate": 1.0, + "score_range": "51-55" + }, + { + "events": 3, + "fraud": 1, + "fraud_rate": 0.3333, + "score_range": "57-60" + }, + { + "events": 4, + "fraud": 3, + "fraud_rate": 0.75, + "score_range": "64-71" + }, + { + "events": 2, + "fraud": 2, + "fraud_rate": 1.0, + "score_range": "77-83" + }, + { + "events": 4, + "fraud": 4, + "fraud_rate": 1.0, + "score_range": "97-100" + } + ], + "champion_challenger": { + "active": true, + "agreement_rate": 0.9645, + "both_flag": 11, + "challenger_only": 0, + "champion_only": 7, + "events": 197, + "neither": 179, + "note": "shadow opinion only; promotion criteria in docs/14", + "version": "gbdt-challenger-1.0" + }, + "confusion": { + "ALLOW": { + "clean": 113, + "fraud": 0 + }, + "BLOCK_REC": { + "clean": 3, + "fraud": 15 + }, + "REVIEW": { + "clean": 64, + "fraud": 2 + } + }, + "design_point_on_test": true, + "evasion_pack": { + "note": "adversarial rings are evaluation-only; never used for calibration", + "strategies": { + "benign_mimicry": { + "events": 20, + "missed_entirely": 1, + "missed_entirely_rate": 0.05, + "not_blocked": 3, + "not_blocked_rate": 0.15 + }, + "partitioned": { + "events": 18, + "missed_entirely": 0, + "missed_entirely_rate": 0.0, + "not_blocked": 2, + "not_blocked_rate": 0.1111 + }, + "rotation": { + "events": 20, + "missed_entirely": 0, + "missed_entirely_rate": 0.0, + "not_blocked": 6, + "not_blocked_rate": 0.3 + }, + "slow_rate": { + "events": 20, + "missed_entirely": 18, + "missed_entirely_rate": 0.9, + "not_blocked": 20, + "not_blocked_rate": 1.0 + } + } + }, + "event_metrics": { + "f1": 0.8571, + "precision": 0.8333, + "recall": 0.8824 + }, + "fp_cost_per_1000": { + "fp_cost_inr": 4888.32, + "gross_saved_inr": 83756.35, + "net_saved_inr": 38664.97, + "review_cost_inr": 40203.05 + }, + "latency_note": "timings in evaluation/latency.json (non-deterministic)", + "model_version": "rules-v1.1", + "precision_ci95": [ + 0.6078, + 0.9416 + ], + "recall_ci95": [ + 0.6566, + 0.9671 + ], + "review_abstentions": { + "clean": 64, + "fraud": 2 + }, + "ring_recall": { + "missed_rings": [], + "per_ring": { + "ring_001": { + "caught": true, + "event_recall": 0.8889, + "events": 9, + "flagged": 8, + "strategy": "sophisticated" + }, + "ring_004": { + "caught": true, + "event_recall": 0.875, + "events": 8, + "flagged": 7, + "strategy": "standard" + } + }, + "rings_caught": 2, + "rings_total": 2, + "sophisticated": { + "ring_001": { + "caught": true, + "event_recall": 0.8889, + "events": 9, + "flagged": 8, + "strategy": "sophisticated" + } + } + }, + "seed": 42, + "test_events": 197, + "test_fraud_events": 17, + "threshold_sensitivity": [ + { + "f1": 0.1838, + "fn": 0, + "fp": 151, + "precision": 0.1012, + "recall": 1.0, + "threshold": 39, + "tp": 17 + }, + { + "f1": 0.8571, + "fn": 2, + "fp": 3, + "precision": 0.8333, + "recall": 0.8824, + "threshold": 49, + "tp": 15 + }, + { + "f1": 0.6207, + "fn": 8, + "fp": 3, + "precision": 0.75, + "recall": 0.5294, + "threshold": 59, + "tp": 9 + } + ], + "thresholds": { + "block": 49, + "review": 42 + }, + "weights": { + "amount_pattern": 7, + "burn_rotate": 8, + "cross_merchant_fanout": 5, + "device_identity_ratio": 13, + "new_identity_burst": 35, + "taint_propagation": 20, + "velocity_72h": 12 + } + }, + "latency": { + "events": 197, + "p50_ms": 1.507, + "p95_ms": 2.684 + } +} \ No newline at end of file diff --git a/console/src/demo/queue.json b/console/src/demo/queue.json new file mode 100644 index 0000000..4504c75 --- /dev/null +++ b/console/src/demo/queue.json @@ -0,0 +1,3740 @@ +[ + { + "verdict_id": "f500a90f-b834-4f03-8874-06a24c03bbe7", + "event_id": "2f72bd3f-41db-5733-a98b-afcd43246409", + "score": 100, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00007", + "mcht_00015", + "mcht_00017", + "mcht_00030", + "mcht_00032", + "mcht_00045" + ], + "shared_devices": [ + { + "device_id": "dev_b7cfe871", + "linked_identities": 6, + "merchant_count": 6 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_ce923c", + "+916733854027", + "cust_752832" + ], + "cluster": { + "customers": 6, + "truncated": false + }, + "customer_id": "cust_ce923c", + "merchant_id": "mcht_00007" + }, + "features": { + "device_identity_ratio": 6.0, + "cross_merchant_fanout": 6, + "taint": 1.0, + "velocity_merchants_72h": 5, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 1.0, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 20.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:28.360766+00:00" + }, + { + "verdict_id": "a9fbcd9e-7d2c-47c9-b2ce-bc3efe27d3ca", + "event_id": "e1fe9187-b4a7-5c91-ae22-154be7095ef1", + "score": 100, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00007", + "mcht_00015", + "mcht_00017", + "mcht_00030", + "mcht_00032", + "mcht_00045" + ], + "shared_devices": [ + { + "device_id": "dev_b7cfe871", + "linked_identities": 7, + "merchant_count": 6 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_1258b0", + "dev_b7cfe871", + "cust_752832" + ], + "cluster": { + "customers": 7, + "truncated": false + }, + "customer_id": "cust_1258b0", + "merchant_id": "mcht_00017" + }, + "features": { + "device_identity_ratio": 7.0, + "cross_merchant_fanout": 6, + "taint": 1.0, + "velocity_merchants_72h": 5, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 1.0, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 20.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:28.367285+00:00" + }, + { + "verdict_id": "62feaa6f-433d-428c-bccc-ce6052a1afd4", + "event_id": "999afc26-106d-5090-8809-7541b982faed", + "score": 100, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00007", + "mcht_00015", + "mcht_00017", + "mcht_00030", + "mcht_00032", + "mcht_00045" + ], + "shared_devices": [ + { + "device_id": "dev_b7cfe871", + "linked_identities": 8, + "merchant_count": 6 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_0ad023", + "+916733854027", + "cust_752832" + ], + "cluster": { + "customers": 8, + "truncated": false + }, + "customer_id": "cust_0ad023", + "merchant_id": "mcht_00032" + }, + "features": { + "device_identity_ratio": 8.0, + "cross_merchant_fanout": 6, + "taint": 1.0, + "velocity_merchants_72h": 5, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 1.0, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 20.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:28.409576+00:00" + }, + { + "verdict_id": "984c4abe-0e03-45e4-87d3-97a29c5389a0", + "event_id": "284cfc5f-c508-5d96-86f5-c62ac40d5756", + "score": 100, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00028", + "mcht_00036", + "mcht_00037", + "mcht_00040", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_23f76327", + "linked_identities": 8, + "merchant_count": 4 + }, + { + "device_id": "dev_3a439f3f", + "linked_identities": 5, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_80b372", + "varun.250@gmail.com", + "cust_e79449" + ], + "cluster": { + "customers": 12, + "truncated": false + }, + "customer_id": "cust_80b372", + "merchant_id": "mcht_00028" + }, + "features": { + "device_identity_ratio": 6.0, + "cross_merchant_fanout": 6, + "taint": 1.0, + "velocity_merchants_72h": 6, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 1.0, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 20.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:32.035253+00:00" + }, + { + "verdict_id": "495a1b9b-3930-4160-a227-23d67587e03c", + "event_id": "12d7acd0-cf33-5ca7-a684-14641b9c7cc2", + "score": 99, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00028", + "mcht_00036", + "mcht_00037", + "mcht_00040", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_23f76327", + "linked_identities": 7, + "merchant_count": 4 + }, + { + "device_id": "dev_3a439f3f", + "linked_identities": 5, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_3616cf", + "+917907457079", + "cust_1610b8" + ], + "cluster": { + "customers": 11, + "truncated": false + }, + "customer_id": "cust_3616cf", + "merchant_id": "mcht_00036" + }, + "features": { + "device_identity_ratio": 5.5, + "cross_merchant_fanout": 6, + "taint": 1.0, + "velocity_merchants_72h": 6, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.916667, + "n_f2": 1.0, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 12.8333, + "cross_merchant_fanout": 20.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:32.028199+00:00" + }, + { + "verdict_id": "48de262e-5eaf-4665-a511-cf6c8b5e37af", + "event_id": "5a173f37-b9a2-5557-9110-7a8a95ad25bf", + "score": 94, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00028", + "mcht_00036", + "mcht_00037", + "mcht_00040", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_23f76327", + "linked_identities": 6, + "merchant_count": 4 + }, + { + "device_id": "dev_3a439f3f", + "linked_identities": 5, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_c36876", + "varun.250@gmail.com", + "cust_e79449" + ], + "cluster": { + "customers": 11, + "truncated": false + }, + "customer_id": "cust_c36876", + "merchant_id": "mcht_00040" + }, + "features": { + "device_identity_ratio": 5.5, + "cross_merchant_fanout": 5, + "taint": 1.0, + "velocity_merchants_72h": 6, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.916667, + "n_f2": 0.75, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 12.8333, + "cross_merchant_fanout": 15.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:31.612407+00:00" + }, + { + "verdict_id": "ad514623-cedc-407f-93d6-2cec4d3cbc7e", + "event_id": "84b92754-505c-5941-8732-611bddbca80e", + "score": 93, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00015", + "mcht_00017", + "mcht_00030", + "mcht_00032", + "mcht_00045" + ], + "shared_devices": [ + { + "device_id": "dev_b7cfe871", + "linked_identities": 5, + "merchant_count": 5 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_dcfc81", + "+916733854027", + "cust_752832" + ], + "cluster": { + "customers": 5, + "truncated": false + }, + "customer_id": "cust_dcfc81", + "merchant_id": "mcht_00045" + }, + "features": { + "device_identity_ratio": 5.0, + "cross_merchant_fanout": 5, + "taint": 1.0, + "velocity_merchants_72h": 5, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.833333, + "n_f2": 0.75, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 11.6667, + "cross_merchant_fanout": 15.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:28.219053+00:00" + }, + { + "verdict_id": "7c7a69e2-28a4-4c2e-9efb-d24cb5c46d7d", + "event_id": "10ebea66-ea56-5010-93a7-28f3073a676d", + "score": 93, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00028", + "mcht_00036", + "mcht_00037", + "mcht_00040", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_23f76327", + "linked_identities": 6, + "merchant_count": 4 + }, + { + "device_id": "dev_3a439f3f", + "linked_identities": 4, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_76f685", + "varun.250@gmail.com", + "cust_e79449" + ], + "cluster": { + "customers": 10, + "truncated": false + }, + "customer_id": "cust_76f685", + "merchant_id": "mcht_00027" + }, + "features": { + "device_identity_ratio": 5.0, + "cross_merchant_fanout": 5, + "taint": 1.0, + "velocity_merchants_72h": 6, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.833333, + "n_f2": 0.75, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 11.6667, + "cross_merchant_fanout": 15.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:31.504882+00:00" + }, + { + "verdict_id": "c8ece011-e5a2-443c-804a-7004bd4bedb7", + "event_id": "3ac159eb-e07f-5353-b39c-dd5a4f5d451d", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00030", + "mcht_00036", + "mcht_00037" + ], + "shared_devices": [ + { + "device_id": "dev_9bd4ad04", + "linked_identities": 7, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_104403", + "dev_9bd4ad04", + "cust_0236ba" + ], + "cluster": { + "customers": 7, + "truncated": false + }, + "customer_id": "cust_104403", + "merchant_id": "mcht_00030" + }, + "features": { + "device_identity_ratio": 7.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.547646+00:00" + }, + { + "verdict_id": "908a7c1d-a8e6-4643-9a01-8869bad03d45", + "event_id": "9711a044-f5c6-5a78-8468-ab745894a65d", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00030", + "mcht_00036", + "mcht_00037" + ], + "shared_devices": [ + { + "device_id": "dev_9bd4ad04", + "linked_identities": 8, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_91ef29", + "dev_9bd4ad04", + "cust_0236ba" + ], + "cluster": { + "customers": 8, + "truncated": false + }, + "customer_id": "cust_91ef29", + "merchant_id": "mcht_00027" + }, + "features": { + "device_identity_ratio": 8.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.558076+00:00" + }, + { + "verdict_id": "70421f5a-959c-4419-83a8-c5fcb5d0b145", + "event_id": "390b3a5e-a3f4-533b-85f0-9c8b84f179a8", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00030", + "mcht_00036", + "mcht_00037" + ], + "shared_devices": [ + { + "device_id": "dev_9bd4ad04", + "linked_identities": 9, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_79a96d", + "dev_9bd4ad04", + "cust_91ef29" + ], + "cluster": { + "customers": 9, + "truncated": false + }, + "customer_id": "cust_79a96d", + "merchant_id": "mcht_00037" + }, + "features": { + "device_identity_ratio": 9.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.586164+00:00" + }, + { + "verdict_id": "94f59adc-b102-43d6-9486-461a32318e52", + "event_id": "efdb048b-660e-5e97-9448-08cc1b10216d", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00010", + "mcht_00020", + "mcht_00023", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_454ad3f1", + "linked_identities": 6, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_2a7e28", + "dev_454ad3f1", + "cust_65ff58" + ], + "cluster": { + "customers": 6, + "truncated": false + }, + "customer_id": "cust_2a7e28", + "merchant_id": "mcht_00031" + }, + "features": { + "device_identity_ratio": 6.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.619840+00:00" + }, + { + "verdict_id": "5f4d1784-2773-4be1-be4b-8174dd915584", + "event_id": "41e73565-1516-586f-9683-603e3642f27a", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00010", + "mcht_00020", + "mcht_00023", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_454ad3f1", + "linked_identities": 7, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_a6a009", + "+916184322974", + "cust_65ff58" + ], + "cluster": { + "customers": 7, + "truncated": false + }, + "customer_id": "cust_a6a009", + "merchant_id": "mcht_00020" + }, + "features": { + "device_identity_ratio": 7.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.623736+00:00" + }, + { + "verdict_id": "51a92a4c-398b-4618-99f0-3316fef9a3a4", + "event_id": "ab573da2-4cca-51c7-8638-8e9c10dc19d5", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00010", + "mcht_00020", + "mcht_00023", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_454ad3f1", + "linked_identities": 8, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_5fc0cf", + "+916184322974", + "cust_65ff58" + ], + "cluster": { + "customers": 8, + "truncated": false + }, + "customer_id": "cust_5fc0cf", + "merchant_id": "mcht_00023" + }, + "features": { + "device_identity_ratio": 8.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.636949+00:00" + }, + { + "verdict_id": "dbb54a1f-97ae-4cd0-b905-31cad7a010e0", + "event_id": "05c53736-7a07-5b9a-bdd6-70b48fd3d06c", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00030", + "mcht_00036", + "mcht_00037" + ], + "shared_devices": [ + { + "device_id": "dev_9bd4ad04", + "linked_identities": 10, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_c24f8c", + "dev_9bd4ad04", + "cust_91ef29" + ], + "cluster": { + "customers": 10, + "truncated": false + }, + "customer_id": "cust_c24f8c", + "merchant_id": "mcht_00036" + }, + "features": { + "device_identity_ratio": 10.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.636949+00:00" + }, + { + "verdict_id": "31baba51-f225-4ac3-9887-b816ffb59535", + "event_id": "3eccd50c-7db3-50f6-9d2f-c6367da2a744", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00030", + "mcht_00036", + "mcht_00037" + ], + "shared_devices": [ + { + "device_id": "dev_9bd4ad04", + "linked_identities": 11, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_bb3ee9", + "+919857644509", + "cust_91ef29" + ], + "cluster": { + "customers": 11, + "truncated": false + }, + "customer_id": "cust_bb3ee9", + "merchant_id": "mcht_00030" + }, + "features": { + "device_identity_ratio": 11.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.702791+00:00" + }, + { + "verdict_id": "d568eb72-e56e-4bbd-97be-cc4928109433", + "event_id": "b77d019a-acab-5582-a1b5-5c3d082a12a5", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00010", + "mcht_00020", + "mcht_00023", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_454ad3f1", + "linked_identities": 9, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_627642", + "+916184322974", + "cust_65ff58" + ], + "cluster": { + "customers": 9, + "truncated": false + }, + "customer_id": "cust_627642", + "merchant_id": "mcht_00010" + }, + "features": { + "device_identity_ratio": 9.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.727930+00:00" + }, + { + "verdict_id": "55b1b80a-8e44-4575-8601-55b890f4c1ca", + "event_id": "3e066db5-811d-55b3-a6ea-c1b9acd08c0f", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00030", + "mcht_00036", + "mcht_00037" + ], + "shared_devices": [ + { + "device_id": "dev_9bd4ad04", + "linked_identities": 12, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_8eb37c", + "kalpanashruti38@gmail.com", + "cust_0236ba" + ], + "cluster": { + "customers": 12, + "truncated": false + }, + "customer_id": "cust_8eb37c", + "merchant_id": "mcht_00027" + }, + "features": { + "device_identity_ratio": 12.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.971205+00:00" + }, + { + "verdict_id": "7116ed7c-2b9f-4de8-bee6-d6569b4ddf2b", + "event_id": "f325280a-4bd1-56fc-a5cc-170de81b0e96", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00030", + "mcht_00036", + "mcht_00037" + ], + "shared_devices": [ + { + "device_id": "dev_9bd4ad04", + "linked_identities": 13, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_9cd653", + "dev_9bd4ad04", + "cust_91ef29" + ], + "cluster": { + "customers": 13, + "truncated": false + }, + "customer_id": "cust_9cd653", + "merchant_id": "mcht_00037" + }, + "features": { + "device_identity_ratio": 13.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.979224+00:00" + }, + { + "verdict_id": "ebd4c824-2e6f-4b93-8e74-153b1ea18588", + "event_id": "2a50dfa4-bd18-5925-9bf0-cee8d0b75810", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00010", + "mcht_00020", + "mcht_00023", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_454ad3f1", + "linked_identities": 10, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_963dba", + "pooja.aarti@gmail.com", + "cust_558e4a" + ], + "cluster": { + "customers": 10, + "truncated": false + }, + "customer_id": "cust_963dba", + "merchant_id": "mcht_00031" + }, + "features": { + "device_identity_ratio": 10.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:26.060453+00:00" + }, + { + "verdict_id": "2a9cf26d-dbe8-4164-97a7-aa1891170be4", + "event_id": "9dcb2907-1ac0-5674-9a7a-b8d78220e8b5", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00010", + "mcht_00020", + "mcht_00023", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_454ad3f1", + "linked_identities": 11, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_13a047", + "+919599112088", + "cust_2a7e28" + ], + "cluster": { + "customers": 11, + "truncated": false + }, + "customer_id": "cust_13a047", + "merchant_id": "mcht_00020" + }, + "features": { + "device_identity_ratio": 11.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:26.068243+00:00" + }, + { + "verdict_id": "45a893b2-03d6-48c8-bd12-6891071f9736", + "event_id": "e844a293-a236-5e11-af73-8a1878dfbcef", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00010", + "mcht_00020", + "mcht_00023", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_454ad3f1", + "linked_identities": 12, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_8bd6d2", + "+916184322974", + "cust_65ff58" + ], + "cluster": { + "customers": 12, + "truncated": false + }, + "customer_id": "cust_8bd6d2", + "merchant_id": "mcht_00023" + }, + "features": { + "device_identity_ratio": 12.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:26.099878+00:00" + }, + { + "verdict_id": "dd7cb2b1-9965-4caf-af8c-81f6627a473b", + "event_id": "de3caabe-a81d-56a4-848d-81b21a3387ab", + "score": 87, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00010", + "mcht_00020", + "mcht_00023", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_454ad3f1", + "linked_identities": 13, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_82b30a", + "+919599112088", + "cust_2a7e28" + ], + "cluster": { + "customers": 13, + "truncated": false + }, + "customer_id": "cust_82b30a", + "merchant_id": "mcht_00010" + }, + "features": { + "device_identity_ratio": 13.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:26.147453+00:00" + }, + { + "verdict_id": "719758cc-534d-4f0d-98ec-d60ea3f9da53", + "event_id": "17871754-331d-54de-9bd9-f44170f93e5a", + "score": 85, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00010", + "mcht_00020", + "mcht_00023", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_454ad3f1", + "linked_identities": 5, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_89b288", + "pooja.aarti@gmail.com", + "cust_558e4a" + ], + "cluster": { + "customers": 5, + "truncated": false + }, + "customer_id": "cust_89b288", + "merchant_id": "mcht_00010" + }, + "features": { + "device_identity_ratio": 5.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.833333, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 11.6667, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.613175+00:00" + }, + { + "verdict_id": "25f9f76c-f274-4b57-8efc-17acad793236", + "event_id": "71e82349-3003-5caf-96b9-63fd3a49ee75", + "score": 85, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00028", + "mcht_00036", + "mcht_00037", + "mcht_00040", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_23f76327", + "linked_identities": 5, + "merchant_count": 4 + }, + { + "device_id": "dev_3a439f3f", + "linked_identities": 3, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_b47ae3", + "+917907457079", + "cust_1610b8" + ], + "cluster": { + "customers": 8, + "truncated": false + }, + "customer_id": "cust_b47ae3", + "merchant_id": "mcht_00043" + }, + "features": { + "device_identity_ratio": 4.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 6, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.666667, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 9.3333, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:31.423244+00:00" + }, + { + "verdict_id": "fd2ea751-c063-437c-bd0c-e30a401ad72a", + "event_id": "0a532bb5-1ecd-5c7a-b2f9-07ec6df02d4d", + "score": 84, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00004", + "mcht_00031", + "mcht_00033", + "mcht_00034", + "mcht_00035", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_c519d85a", + "linked_identities": 5, + "merchant_count": 4 + }, + { + "device_id": "dev_d86c8695", + "linked_identities": 2, + "merchant_count": 2 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_320510", + "dev_c519d85a", + "cust_759039" + ], + "cluster": { + "customers": 7, + "truncated": false + }, + "customer_id": "cust_320510", + "merchant_id": "mcht_00004" + }, + "features": { + "device_identity_ratio": 3.5, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 6, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.583333, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 8.1667, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:29.802040+00:00" + }, + { + "verdict_id": "03a0bc49-28c2-45ef-bb59-a269b18ede31", + "event_id": "46ee34f4-dc22-56c4-b0d1-c0d4536e3c6c", + "score": 84, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00004", + "mcht_00031", + "mcht_00033", + "mcht_00035", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_c519d85a", + "linked_identities": 5, + "merchant_count": 4 + }, + { + "device_id": "dev_d86c8695", + "linked_identities": 3, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_0eed60", + "+919361849009", + "cust_759039" + ], + "cluster": { + "customers": 7, + "truncated": false + }, + "customer_id": "cust_0eed60", + "merchant_id": "mcht_00031" + }, + "features": { + "device_identity_ratio": 3.5, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 5, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.583333, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 8.1667, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:29.892842+00:00" + }, + { + "verdict_id": "6895ff78-849b-4986-a9db-6a5b44930e62", + "event_id": "1e480b14-e320-5869-a661-a49c62974424", + "score": 84, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00028", + "mcht_00036", + "mcht_00037", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_23f76327", + "linked_identities": 6, + "merchant_count": 4 + }, + { + "device_id": "dev_3a439f3f", + "linked_identities": 3, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_de5b28", + "varun.250@gmail.com", + "cust_e79449" + ], + "cluster": { + "customers": 7, + "truncated": false + }, + "customer_id": "cust_de5b28", + "merchant_id": "mcht_00037" + }, + "features": { + "device_identity_ratio": 3.5, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 5, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.583333, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 8.1667, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:31.447133+00:00" + }, + { + "verdict_id": "32fff895-c35d-40d3-9ea5-f6c0f946f94d", + "event_id": "a0cc1995-aed8-5b2b-b245-0ffce537fe7e", + "score": 83, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00010", + "mcht_00020", + "mcht_00023", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_454ad3f1", + "linked_identities": 4, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_4c68ed", + "+916184322974", + "cust_65ff58" + ], + "cluster": { + "customers": 4, + "truncated": false + }, + "customer_id": "cust_4c68ed", + "merchant_id": "mcht_00023" + }, + "features": { + "device_identity_ratio": 4.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.666667, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 9.3333, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.604692+00:00" + }, + { + "verdict_id": "d898f502-4b53-427f-8270-46842678bb20", + "event_id": "2ffa34ef-6004-5bfe-949a-c24beca4d404", + "score": 83, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00028", + "mcht_00036", + "mcht_00037", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_23f76327", + "linked_identities": 4, + "merchant_count": 3 + }, + { + "device_id": "dev_3a439f3f", + "linked_identities": 3, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_4a4cca", + "+917644681740", + "cust_ddb568" + ], + "cluster": { + "customers": 6, + "truncated": false + }, + "customer_id": "cust_4a4cca", + "merchant_id": "mcht_00028" + }, + "features": { + "device_identity_ratio": 3.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 5, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.5, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 7.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:31.407015+00:00" + }, + { + "verdict_id": "26af5e4e-7858-4826-b43b-73b248f936fd", + "event_id": "fe072e87-39cc-52e3-9d20-5fe5a6952573", + "score": 82, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00004", + "mcht_00031", + "mcht_00033", + "mcht_00034", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_c519d85a", + "linked_identities": 4, + "merchant_count": 4 + }, + { + "device_id": "dev_d86c8695", + "linked_identities": 2, + "merchant_count": 2 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_e137c5", + "dev_c519d85a", + "cust_759039" + ], + "cluster": { + "customers": 5, + "truncated": false + }, + "customer_id": "cust_e137c5", + "merchant_id": "mcht_00033" + }, + "features": { + "device_identity_ratio": 2.5, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 5, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.416667, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 5.8333, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:29.794211+00:00" + }, + { + "verdict_id": "c6382728-2813-4596-9244-dfd30390e8e8", + "event_id": "2e27cf12-b5da-5e4e-86f9-b0d0374f9757", + "score": 82, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00028", + "mcht_00037", + "mcht_00040", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_3a439f3f", + "linked_identities": 3, + "merchant_count": 3 + }, + { + "device_id": "dev_23f76327", + "linked_identities": 2, + "merchant_count": 2 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_3356bd", + "+917907457079", + "cust_1610b8" + ], + "cluster": { + "customers": 5, + "truncated": false + }, + "customer_id": "cust_3356bd", + "merchant_id": "mcht_00040" + }, + "features": { + "device_identity_ratio": 2.5, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 5, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.416667, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 5.8333, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:31.340079+00:00" + }, + { + "verdict_id": "85f65da0-3fad-4b4d-b542-b355da5af9d6", + "event_id": "fd4f732b-01f8-50f8-ad8e-07157f633ff4", + "score": 82, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00028", + "mcht_00036", + "mcht_00037", + "mcht_00040" + ], + "shared_devices": [ + { + "device_id": "dev_23f76327", + "linked_identities": 3, + "merchant_count": 3 + }, + { + "device_id": "dev_3a439f3f", + "linked_identities": 3, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_e79449", + "+917644681740", + "cust_ddb568" + ], + "cluster": { + "customers": 5, + "truncated": false + }, + "customer_id": "cust_e79449", + "merchant_id": "mcht_00036" + }, + "features": { + "device_identity_ratio": 2.5, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 5, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.416667, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 5.8333, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:31.351379+00:00" + }, + { + "verdict_id": "bd68d641-9282-4b15-b534-208c068f956f", + "event_id": "d2e2495b-e3f1-5717-989f-71b49c040e53", + "score": 78, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00006", + "mcht_00007", + "mcht_00015", + "mcht_00026", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_aed2add1", + "linked_identities": 3, + "merchant_count": 3 + }, + { + "device_id": "dev_b92f2974", + "linked_identities": 3, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_37d9f1", + "dev_b92f2974", + "cust_d895e7" + ], + "cluster": { + "customers": 6, + "truncated": false + }, + "customer_id": "cust_37d9f1", + "merchant_id": "mcht_00015" + }, + "features": { + "device_identity_ratio": 3.0, + "cross_merchant_fanout": 3, + "taint": 1.0, + "velocity_merchants_72h": 5, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.5, + "n_f2": 0.25, + "n_f3": 1.0, + "n_f4": 1.0, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 7.0, + "cross_merchant_fanout": 5.0, + "taint_propagation": 24.0, + "velocity_72h": 14.0, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:31.044706+00:00" + }, + { + "verdict_id": "eac989ca-deb2-4829-afaf-357a1b588c96", + "event_id": "ece0e452-1ed0-5c35-b713-4b811042ab7e", + "score": 77, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00027", + "mcht_00030", + "mcht_00036", + "mcht_00037" + ], + "shared_devices": [ + { + "device_id": "dev_9bd4ad04", + "linked_identities": 6, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [], + "cluster": { + "customers": 6, + "truncated": false + }, + "customer_id": "cust_0236ba", + "merchant_id": "mcht_00036" + }, + "features": { + "device_identity_ratio": 6.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 0, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 1.0, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 0.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 0.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.539177+00:00" + }, + { + "verdict_id": "f325adda-15c7-4bec-b57a-734f6f6baa38", + "event_id": "4844ce9f-01ae-57b9-a799-6886e6407990", + "score": 76, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00006", + "mcht_00015", + "mcht_00025", + "mcht_00026" + ], + "shared_devices": [ + { + "device_id": "dev_b92f2974", + "linked_identities": 5, + "merchant_count": 4 + }, + { + "device_id": "dev_aed2add1", + "linked_identities": 3, + "merchant_count": 3 + }, + { + "device_id": "dev_e6130771", + "linked_identities": 1, + "merchant_count": 1 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_8175bf", + "+918941154171", + "cust_d772a3" + ], + "cluster": { + "customers": 7, + "truncated": false + }, + "customer_id": "cust_8175bf", + "merchant_id": "mcht_00025" + }, + "features": { + "device_identity_ratio": 2.333, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 3, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.388833, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.6, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 5.4437, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 8.4, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:31.274518+00:00" + }, + { + "verdict_id": "7ba6df77-1a90-42a9-9e84-655ebfc89c73", + "event_id": "1b4d4aad-1a2a-532b-ba0e-3c41d38913d2", + "score": 75, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00003", + "mcht_00005", + "mcht_00034" + ], + "shared_devices": [ + { + "device_id": "dev_aa87ab9f", + "linked_identities": 5, + "merchant_count": 3 + }, + { + "device_id": "dev_f8d45285", + "linked_identities": 3, + "merchant_count": 2 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_7278ee", + "dev_f8d45285", + "cust_47d46e" + ], + "cluster": { + "customers": 8, + "truncated": false + }, + "customer_id": "cust_7278ee", + "merchant_id": "mcht_00034" + }, + "features": { + "device_identity_ratio": 4.0, + "cross_merchant_fanout": 3, + "taint": 1.0, + "velocity_merchants_72h": 3, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.666667, + "n_f2": 0.25, + "n_f3": 1.0, + "n_f4": 0.6, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 9.3333, + "cross_merchant_fanout": 5.0, + "taint_propagation": 24.0, + "velocity_72h": 8.4, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:26.365483+00:00" + }, + { + "verdict_id": "a29491dd-b45e-4cb6-97e6-ed0faf6c9d54", + "event_id": "194355ce-5646-552a-ac17-1c657afae840", + "score": 75, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00008", + "mcht_00042", + "mcht_00044" + ], + "shared_devices": [ + { + "device_id": "dev_ef297651", + "linked_identities": 4, + "merchant_count": 4 + }, + { + "device_id": "dev_a31d30dd", + "linked_identities": 2, + "merchant_count": 2 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_b13a88", + "nikita.tanvi@gmail.com", + "cust_9acd17" + ], + "cluster": { + "customers": 4, + "truncated": false + }, + "customer_id": "cust_b13a88", + "merchant_id": "mcht_00008" + }, + "features": { + "device_identity_ratio": 2.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 3, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.333333, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.6, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 4.6667, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 8.4, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:28.862828+00:00" + }, + { + "verdict_id": "fd1d889d-39f2-4df0-b9b7-9525798e8f7a", + "event_id": "6fc5fd49-7406-5b79-87d6-a311e477c421", + "score": 75, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00008", + "mcht_00042", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_ef297651", + "linked_identities": 4, + "merchant_count": 4 + }, + { + "device_id": "dev_a31d30dd", + "linked_identities": 3, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_68e0ca", + "dev_a31d30dd", + "cust_9acd17" + ], + "cluster": { + "customers": 4, + "truncated": false + }, + "customer_id": "cust_68e0ca", + "merchant_id": "mcht_00043" + }, + "features": { + "device_identity_ratio": 2.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 3, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.333333, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.6, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 4.6667, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 8.4, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:28.994517+00:00" + }, + { + "verdict_id": "db254b6d-d503-4c9f-8129-de2c9e5d7651", + "event_id": "9813e1c6-805c-5712-b68b-1e5c7b7e93a8", + "score": 75, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00006", + "mcht_00015", + "mcht_00025", + "mcht_00026" + ], + "shared_devices": [ + { + "device_id": "dev_b92f2974", + "linked_identities": 4, + "merchant_count": 3 + }, + { + "device_id": "dev_aed2add1", + "linked_identities": 3, + "merchant_count": 3 + }, + { + "device_id": "dev_e6130771", + "linked_identities": 1, + "merchant_count": 1 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_c7edd2", + "+918941154171", + "cust_d772a3" + ], + "cluster": { + "customers": 6, + "truncated": false + }, + "customer_id": "cust_c7edd2", + "merchant_id": "mcht_00026" + }, + "features": { + "device_identity_ratio": 2.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 3, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.333333, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.6, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 4.6667, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 8.4, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:31.109130+00:00" + }, + { + "verdict_id": "b79cf13b-9516-4ed1-8f66-f2d2982220c5", + "event_id": "974e961c-97ba-55e7-8ff0-1132e3eca07c", + "score": 73, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00015", + "mcht_00017", + "mcht_00030", + "mcht_00032" + ], + "shared_devices": [ + { + "device_id": "dev_b7cfe871", + "linked_identities": 4, + "merchant_count": 4 + } + ], + "taint": 1.0, + "taint_path": [], + "cluster": { + "customers": 4, + "truncated": false + }, + "customer_id": "cust_752832", + "merchant_id": "mcht_00015" + }, + "features": { + "device_identity_ratio": 4.0, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 0, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.666667, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 0.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 9.3333, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 0.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:28.022979+00:00" + }, + { + "verdict_id": "0f459106-aed6-4609-801e-f21893584776", + "event_id": "e82332b4-3156-5d3d-a4e7-2d6f979e04bf", + "score": 73, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00004", + "mcht_00031", + "mcht_00034", + "mcht_00035" + ], + "shared_devices": [ + { + "device_id": "dev_c519d85a", + "linked_identities": 3, + "merchant_count": 3 + }, + { + "device_id": "dev_d86c8695", + "linked_identities": 1, + "merchant_count": 1 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_ea30c1", + "deepaananya37@gmail.com", + "cust_759039" + ], + "cluster": { + "customers": 4, + "truncated": false + }, + "customer_id": "cust_ea30c1", + "merchant_id": "mcht_00035" + }, + "features": { + "device_identity_ratio": 2.0, + "cross_merchant_fanout": 3, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.333333, + "n_f2": 0.25, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 4.6667, + "cross_merchant_fanout": 5.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:29.768823+00:00" + }, + { + "verdict_id": "81c42b7e-b1cc-4eb0-8d9f-190379aacbcf", + "event_id": "6a871e07-9918-53fd-8d4d-83c993a7bd66", + "score": 73, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00004", + "mcht_00031", + "mcht_00035", + "mcht_00043" + ], + "shared_devices": [ + { + "device_id": "dev_c519d85a", + "linked_identities": 3, + "merchant_count": 3 + }, + { + "device_id": "dev_d86c8695", + "linked_identities": 2, + "merchant_count": 2 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_182853", + "+919361849009", + "cust_759039" + ], + "cluster": { + "customers": 4, + "truncated": false + }, + "customer_id": "cust_182853", + "merchant_id": "mcht_00043" + }, + "features": { + "device_identity_ratio": 2.0, + "cross_merchant_fanout": 3, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.333333, + "n_f2": 0.25, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 4.6667, + "cross_merchant_fanout": 5.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:29.785428+00:00" + }, + { + "verdict_id": "fa607c38-01da-4855-993b-551093d721d5", + "event_id": "b0391943-9916-5ee3-a723-cb2b9018cb45", + "score": 72, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00010", + "mcht_00020", + "mcht_00031" + ], + "shared_devices": [ + { + "device_id": "dev_454ad3f1", + "linked_identities": 3, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_2647dd", + "+916184322974", + "cust_65ff58" + ], + "cluster": { + "customers": 3, + "truncated": false + }, + "customer_id": "cust_2647dd", + "merchant_id": "mcht_00020" + }, + "features": { + "device_identity_ratio": 3.0, + "cross_merchant_fanout": 3, + "taint": 1.0, + "velocity_merchants_72h": 3, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.5, + "n_f2": 0.25, + "n_f3": 1.0, + "n_f4": 0.6, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 7.0, + "cross_merchant_fanout": 5.0, + "taint_propagation": 24.0, + "velocity_72h": 8.4, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.596836+00:00" + }, + { + "verdict_id": "f1c1c4cb-2a7d-43fa-81ac-5277930f432c", + "event_id": "7ce7933a-ad64-59da-98d4-8eef3b9334a1", + "score": 72, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00003", + "mcht_00014", + "mcht_00022" + ], + "shared_devices": [ + { + "device_id": "dev_221ce5ac", + "linked_identities": 6, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_58f803", + "dev_221ce5ac", + "cust_b1e431" + ], + "cluster": { + "customers": 6, + "truncated": false + }, + "customer_id": "cust_58f803", + "merchant_id": "mcht_00014" + }, + "features": { + "device_identity_ratio": 6.0, + "cross_merchant_fanout": 3, + "taint": 1.0, + "velocity_merchants_72h": 2, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 0.666667, + "n_f1": 1.0, + "n_f2": 0.25, + "n_f3": 1.0, + "n_f4": 0.4, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 0.666667 + }, + "contributions": { + "device_identity_ratio": 14.0, + "cross_merchant_fanout": 5.0, + "taint_propagation": 24.0, + "velocity_72h": 5.6, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 9.3333 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:28.348716+00:00" + }, + { + "verdict_id": "ed1fd07e-7beb-47ee-a888-b7a78ac6d6d3", + "event_id": "9166a5a3-a6ef-5186-972e-c2a790d2981e", + "score": 72, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00004", + "mcht_00031", + "mcht_00034" + ], + "shared_devices": [ + { + "device_id": "dev_c519d85a", + "linked_identities": 3, + "merchant_count": 3 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_74cb45", + "dev_c519d85a", + "cust_759039" + ], + "cluster": { + "customers": 3, + "truncated": false + }, + "customer_id": "cust_74cb45", + "merchant_id": "mcht_00034" + }, + "features": { + "device_identity_ratio": 3.0, + "cross_merchant_fanout": 3, + "taint": 1.0, + "velocity_merchants_72h": 3, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.5, + "n_f2": 0.25, + "n_f3": 1.0, + "n_f4": 0.6, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 7.0, + "cross_merchant_fanout": 5.0, + "taint_propagation": 24.0, + "velocity_72h": 8.4, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:29.768823+00:00" + }, + { + "verdict_id": "f23aa11a-c7b7-4d41-bbdb-5b7d32aafaf2", + "event_id": "8f76a735-7f3e-5397-acc7-e622e7f1927c", + "score": 71, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00006", + "mcht_00015", + "mcht_00025", + "mcht_00026" + ], + "shared_devices": [ + { + "device_id": "dev_b92f2974", + "linked_identities": 2, + "merchant_count": 2 + }, + { + "device_id": "dev_aed2add1", + "linked_identities": 1, + "merchant_count": 1 + }, + { + "device_id": "dev_e6130771", + "linked_identities": 1, + "merchant_count": 1 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_d895e7", + "+918941154171", + "cust_d772a3" + ], + "cluster": { + "customers": 4, + "truncated": false + }, + "customer_id": "cust_d895e7", + "merchant_id": "mcht_00006" + }, + "features": { + "device_identity_ratio": 1.333, + "cross_merchant_fanout": 3, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.222167, + "n_f2": 0.25, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 3.1103, + "cross_merchant_fanout": 5.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:30.580373+00:00" + }, + { + "verdict_id": "782da221-a33f-43c8-9be9-b699e3b22a8c", + "event_id": "d3a08af0-b59b-541b-9222-b87236106564", + "score": 70, + "verdict": "BLOCK_REC", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_BURN_ROTATE", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00003", + "mcht_00005", + "mcht_00034" + ], + "shared_devices": [ + { + "device_id": "dev_aa87ab9f", + "linked_identities": 3, + "merchant_count": 2 + }, + { + "device_id": "dev_f8d45285", + "linked_identities": 2, + "merchant_count": 1 + } + ], + "taint": 1.0, + "taint_path": [ + "cust_149071", + "meera.tanvi@gmail.com", + "cust_47d46e" + ], + "cluster": { + "customers": 4, + "truncated": false + }, + "customer_id": "cust_149071", + "merchant_id": "mcht_00034" + }, + "features": { + "device_identity_ratio": 2.0, + "cross_merchant_fanout": 3, + "taint": 1.0, + "velocity_merchants_72h": 3, + "burn_rotate": 1, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.333333, + "n_f2": 0.25, + "n_f3": 1.0, + "n_f4": 0.6, + "n_f5": 1.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 4.6667, + "cross_merchant_fanout": 5.0, + "taint_propagation": 24.0, + "velocity_72h": 8.4, + "burn_rotate": 10.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:25.901747+00:00" + }, + { + "verdict_id": "b63ec86d-f65d-4efb-8c1c-434bbc81c5f4", + "event_id": "0782118c-c266-599f-9967-4e1176bbf324", + "score": 69, + "verdict": "REVIEW", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00008", + "mcht_00042", + "mcht_00043", + "mcht_00044" + ], + "shared_devices": [ + { + "device_id": "dev_ef297651", + "linked_identities": 4, + "merchant_count": 4 + }, + { + "device_id": "dev_a31d30dd", + "linked_identities": 1, + "merchant_count": 1 + } + ], + "taint": 1.0, + "taint_path": [], + "cluster": { + "customers": 5, + "truncated": false + }, + "customer_id": "cust_9acd17", + "merchant_id": "mcht_00042" + }, + "features": { + "device_identity_ratio": 2.5, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 0, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.416667, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 0.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 5.8333, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 0.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:28.829800+00:00" + }, + { + "verdict_id": "01b479d6-82a9-493a-874e-d4255a672bc1", + "event_id": "8ad17933-2ad1-51fb-8d86-da9c8661c2bd", + "score": 69, + "verdict": "REVIEW", + "reason_codes": [ + "RNG_DEVICE_FANOUT", + "RNG_IDENTITY_FARM", + "RNG_TAINT_LINK", + "RNG_VELOCITY", + "RNG_AMOUNT_PATTERN" + ], + "evidence": { + "linked_merchants": [ + "mcht_00008", + "mcht_00042", + "mcht_00043", + "mcht_00044" + ], + "shared_devices": [ + { + "device_id": "dev_ef297651", + "linked_identities": 5, + "merchant_count": 4 + }, + { + "device_id": "dev_a31d30dd", + "linked_identities": 3, + "merchant_count": 3 + }, + { + "device_id": "dev_5aa8722b", + "linked_identities": 1, + "merchant_count": 1 + } + ], + "taint": 1.0, + "taint_path": [], + "cluster": { + "customers": 8, + "truncated": false + }, + "customer_id": "cust_9c0e33", + "merchant_id": "mcht_00042" + }, + "features": { + "device_identity_ratio": 2.667, + "cross_merchant_fanout": 4, + "taint": 1.0, + "velocity_merchants_72h": 4, + "burn_rotate": 0, + "amount_band_hit": 1.0, + "new_identity_fraction": 1.0, + "n_f1": 0.4445, + "n_f2": 0.5, + "n_f3": 1.0, + "n_f4": 0.8, + "n_f5": 0.0, + "n_f6": 1.0, + "n_f7": 1.0 + }, + "contributions": { + "device_identity_ratio": 6.223, + "cross_merchant_fanout": 10.0, + "taint_propagation": 24.0, + "velocity_72h": 11.2, + "burn_rotate": 0.0, + "amount_pattern": 4.0, + "new_identity_burst": 14.0 + }, + "model_version": "rules-v1.0", + "schema_version": "1", + "explanation": null, + "explanation_status": "PENDING", + "challenger": null, + "created_at": "2026-08-30T17:43:29.283967+00:00" + } +] \ No newline at end of file diff --git a/console/src/demo/scenario.json b/console/src/demo/scenario.json new file mode 100644 index 0000000..5a43183 --- /dev/null +++ b/console/src/demo/scenario.json @@ -0,0 +1,328 @@ +{ + "ring_id": "ring_003", + "strategy": "standard", + "merchants": [ + "mcht_00027", + "mcht_00028", + "mcht_00036", + "mcht_00037", + "mcht_00040", + "mcht_00043" + ], + "events": [ + { + "event_id": "0f37a669-7c56-5c94-baaf-a32acc3dea9d", + "merchant_id": "mcht_00028", + "customer_id": "cust_9b0067", + "amount_paise": 104600, + "currency": "INR", + "upi_vpa": "priya_6791@okaxis", + "phone": "+917907457079", + "device_id": "dev_23f76327", + "email": "varun.250@gmail.com", + "ip": "103.43.154.112", + "ts": "2026-08-13T05:43:19.112809", + "payment_method": "upi", + "prior_outcome": null + }, + { + "event_id": "ea6900b4-8bb2-5434-beac-39c7d201d4ff", + "merchant_id": "mcht_00043", + "customer_id": "cust_1610b8", + "amount_paise": 156400, + "currency": "INR", + "upi_vpa": "priya_6791@okaxis", + "phone": "+917907457079", + "device_id": "dev_3a439f3f", + "email": "varunmeera12@gmail.com", + "ip": "103.97.91.71", + "ts": "2026-08-13T09:52:13.992163", + "payment_method": "upi", + "prior_outcome": "refund_abuse" + }, + { + "event_id": "5f99e978-0919-517e-8834-82a1c030810e", + "merchant_id": "mcht_00037", + "customer_id": "cust_2148dd", + "amount_paise": 135900, + "currency": "INR", + "upi_vpa": "riya6059@axl", + "phone": "+917907457079", + "device_id": "dev_23f76327", + "email": "varunmeera12@gmail.com", + "ip": "103.43.154.112", + "ts": "2026-08-13T11:45:21.993498", + "payment_method": "upi", + "prior_outcome": null + }, + { + "event_id": "2d2baf61-b13d-5840-8a4f-c329eee233a0", + "merchant_id": "mcht_00027", + "customer_id": "cust_ddb568", + "amount_paise": 54000, + "currency": "INR", + "upi_vpa": "riya6059@axl", + "phone": "+917644681740", + "device_id": "dev_3a439f3f", + "email": "varunmeera12@gmail.com", + "ip": "103.97.91.71", + "ts": "2026-08-13T12:54:59.380262", + "payment_method": "upi", + "prior_outcome": "chargeback" + }, + { + "event_id": "2e27cf12-b5da-5e4e-86f9-b0d0374f9757", + "merchant_id": "mcht_00040", + "customer_id": "cust_3356bd", + "amount_paise": 62500, + "currency": "INR", + "upi_vpa": "karan_6092@paytm", + "phone": "+917907457079", + "device_id": "dev_3a439f3f", + "email": "varun.meera@gmail.com", + "ip": "103.97.91.71", + "ts": "2026-08-13T13:35:00.937127", + "payment_method": "upi", + "prior_outcome": null + }, + { + "event_id": "fd4f732b-01f8-50f8-ad8e-07157f633ff4", + "merchant_id": "mcht_00036", + "customer_id": "cust_e79449", + "amount_paise": 89400, + "currency": "INR", + "upi_vpa": "karan_6092@paytm", + "phone": "+917644681740", + "device_id": "dev_23f76327", + "email": "varun.250@gmail.com", + "ip": "103.43.154.112", + "ts": "2026-08-13T15:03:08.111010", + "payment_method": "upi", + "prior_outcome": "chargeback" + }, + { + "event_id": "2ffa34ef-6004-5bfe-949a-c24beca4d404", + "merchant_id": "mcht_00028", + "customer_id": "cust_4a4cca", + "amount_paise": 123900, + "currency": "INR", + "upi_vpa": "sonal1052@okhdfcbank", + "phone": "+917644681740", + "device_id": "dev_23f76327", + "email": "varunmeera12@gmail.com", + "ip": "103.43.154.112", + "ts": "2026-08-13T16:50:09.342057", + "payment_method": "upi", + "prior_outcome": null + }, + { + "event_id": "71e82349-3003-5caf-96b9-63fd3a49ee75", + "merchant_id": "mcht_00043", + "customer_id": "cust_b47ae3", + "amount_paise": 97900, + "currency": "INR", + "upi_vpa": "sonal1052@okhdfcbank", + "phone": "+917907457079", + "device_id": "dev_23f76327", + "email": "varunmeera12@gmail.com", + "ip": "103.68.142.236", + "ts": "2026-08-13T21:42:06.551641", + "payment_method": "upi", + "prior_outcome": null + }, + { + "event_id": "1e480b14-e320-5869-a661-a49c62974424", + "merchant_id": "mcht_00037", + "customer_id": "cust_de5b28", + "amount_paise": 160700, + "currency": "INR", + "upi_vpa": "sonal1052@okhdfcbank", + "phone": "+917644681740", + "device_id": "dev_23f76327", + "email": "varun.250@gmail.com", + "ip": "103.68.142.236", + "ts": "2026-08-13T23:26:53.231329", + "payment_method": "upi", + "prior_outcome": null + }, + { + "event_id": "10ebea66-ea56-5010-93a7-28f3073a676d", + "merchant_id": "mcht_00027", + "customer_id": "cust_76f685", + "amount_paise": 114800, + "currency": "INR", + "upi_vpa": "sonal1052@okhdfcbank", + "phone": "+917907457079", + "device_id": "dev_3a439f3f", + "email": "varun.250@gmail.com", + "ip": "103.68.142.236", + "ts": "2026-08-14T03:22:40.894055", + "payment_method": "upi", + "prior_outcome": null + }, + { + "event_id": "5a173f37-b9a2-5557-9110-7a8a95ad25bf", + "merchant_id": "mcht_00040", + "customer_id": "cust_c36876", + "amount_paise": 87300, + "currency": "INR", + "upi_vpa": "sonal1052@okhdfcbank", + "phone": "+917907457079", + "device_id": "dev_3a439f3f", + "email": "varun.250@gmail.com", + "ip": "103.68.142.236", + "ts": "2026-08-14T21:20:12.689535", + "payment_method": "upi", + "prior_outcome": null + }, + { + "event_id": "12d7acd0-cf33-5ca7-a684-14641b9c7cc2", + "merchant_id": "mcht_00036", + "customer_id": "cust_3616cf", + "amount_paise": 63400, + "currency": "INR", + "upi_vpa": "sonal1052@okhdfcbank", + "phone": "+917907457079", + "device_id": "dev_23f76327", + "email": "varun.meera@gmail.com", + "ip": "103.97.91.71", + "ts": "2026-08-16T11:46:07.894141", + "payment_method": "upi", + "prior_outcome": null + }, + { + "event_id": "284cfc5f-c508-5d96-86f5-c62ac40d5756", + "merchant_id": "mcht_00028", + "customer_id": "cust_80b372", + "amount_paise": 109200, + "currency": "INR", + "upi_vpa": "sonal1052@okhdfcbank", + "phone": "+917907457079", + "device_id": "dev_23f76327", + "email": "varun.250@gmail.com", + "ip": "103.43.154.112", + "ts": "2026-08-16T13:37:18.543789", + "payment_method": "upi", + "prior_outcome": "refund_abuse" + } + ], + "recorded_feed": [ + { + "index": 0, + "merchant": "mcht_00028", + "customer": "cust_9b0067", + "amount_paise": 104600, + "score": 23, + "verdict": "ALLOW", + "duplicate": false + }, + { + "index": 1, + "merchant": "mcht_00043", + "customer": "cust_1610b8", + "amount_paise": 156400, + "score": 50, + "verdict": "REVIEW", + "duplicate": false + }, + { + "index": 2, + "merchant": "mcht_00037", + "customer": "cust_2148dd", + "amount_paise": 135900, + "score": 69, + "verdict": "REVIEW", + "duplicate": false + }, + { + "index": 3, + "merchant": "mcht_00027", + "customer": "cust_ddb568", + "amount_paise": 54000, + "score": 69, + "verdict": "REVIEW", + "duplicate": false + }, + { + "index": 4, + "merchant": "mcht_00040", + "customer": "cust_3356bd", + "amount_paise": 62500, + "score": 82, + "verdict": "BLOCK_REC", + "duplicate": false + }, + { + "index": 5, + "merchant": "mcht_00036", + "customer": "cust_e79449", + "amount_paise": 89400, + "score": 82, + "verdict": "BLOCK_REC", + "duplicate": false + }, + { + "index": 6, + "merchant": "mcht_00028", + "customer": "cust_4a4cca", + "amount_paise": 123900, + "score": 83, + "verdict": "BLOCK_REC", + "duplicate": false + }, + { + "index": 7, + "merchant": "mcht_00043", + "customer": "cust_b47ae3", + "amount_paise": 97900, + "score": 85, + "verdict": "BLOCK_REC", + "duplicate": false + }, + { + "index": 8, + "merchant": "mcht_00037", + "customer": "cust_de5b28", + "amount_paise": 160700, + "score": 84, + "verdict": "BLOCK_REC", + "duplicate": false + }, + { + "index": 9, + "merchant": "mcht_00027", + "customer": "cust_76f685", + "amount_paise": 114800, + "score": 93, + "verdict": "BLOCK_REC", + "duplicate": false + }, + { + "index": 10, + "merchant": "mcht_00040", + "customer": "cust_c36876", + "amount_paise": 87300, + "score": 94, + "verdict": "BLOCK_REC", + "duplicate": false + }, + { + "index": 11, + "merchant": "mcht_00036", + "customer": "cust_3616cf", + "amount_paise": 63400, + "score": 99, + "verdict": "BLOCK_REC", + "duplicate": false + }, + { + "index": 12, + "merchant": "mcht_00028", + "customer": "cust_80b372", + "amount_paise": 109200, + "score": 100, + "verdict": "BLOCK_REC", + "duplicate": false + } + ] +} \ No newline at end of file diff --git a/console/src/lib/api.ts b/console/src/lib/api.ts index 1f230bb..435c920 100644 --- a/console/src/lib/api.ts +++ b/console/src/lib/api.ts @@ -9,6 +9,8 @@ import { useEffect, useState } from "react"; proxy and per-merchant OAuth (documented in the security doc). */ +import { DEMO_MODE, demoCluster, demoEvaluation, demoQueue, demoScenario } from "@/lib/demo"; + const FALLBACK_BASE = "http://localhost:8000"; const FALLBACK_KEY = "dev-sentinel-key"; @@ -34,7 +36,7 @@ export interface Verdict { features: Record; contributions: Record; explanation: string | null; - explanation_status: "PENDING" | "DONE" | "SKIPPED" | "FAILED"; + explanation_status: "PENDING" | "DONE" | "SKIPPED" | "FAILED" | "CAP_REACHED"; created_at: string; } @@ -81,6 +83,11 @@ export function useVerdictQueue(filter: string | null) { useEffect(() => { let cancelled = false; const load = async () => { + if (DEMO_MODE) { + setRows(demoQueue()); + setLoaded(true); + return; + } try { const params = new URLSearchParams({ limit: "50" }); if (filter) params.set("verdict", filter); @@ -111,6 +118,7 @@ export async function fetchVerdict(base: string, key: string, eventId: string): } export async function fetchCluster(base: string, key: string, customerId: string) { + if (DEMO_MODE) return demoCluster(customerId); return request<{ customer_id: string; nodes: { type: string; id: string; taint: number }[]; @@ -120,6 +128,7 @@ export async function fetchCluster(base: string, key: string, customerId: string } export async function fetchEvaluation(base: string, key: string) { + if (DEMO_MODE) return demoEvaluation(); return request<{ metrics: Record; latency?: { p50_ms: number; p95_ms: number; events: number }; @@ -127,9 +136,24 @@ export async function fetchEvaluation(base: string, key: string) { } export async function fetchScenario(base: string, key: string): Promise { + if (DEMO_MODE) return demoScenario(); return request(base, key, "/v1/demo/scenario"); } +export async function explainLive( + base: string, + key: string, + eventId: string +): Promise<{ explanation: string | null; explanation_status: string; cap: Record }> { + const response = await fetch(`${base}/v1/explain/${eventId}`, { + method: "POST", + headers: { "X-API-Key": key, "Content-Type": "application/json" }, + }); + const body = await response.json(); + if (!response.ok) throw new Error(`${response.status} ${JSON.stringify(body).slice(0, 160)}`); + return body; +} + export async function ingestEvent(base: string, key: string, event: ScenarioEvent) { const response = await fetch(`${base}/v1/events`, { method: "POST", diff --git a/console/src/lib/demo.ts b/console/src/lib/demo.ts new file mode 100644 index 0000000..f5e02ef --- /dev/null +++ b/console/src/lib/demo.ts @@ -0,0 +1,49 @@ +/* + Static demo mode: when NEXT_PUBLIC_DEMO=1 the console serves baked + fixtures instead of the live API, enabling a zero-backend, zero-key, + zero-cost deployment (the clickable outreach link). The replay is a + recorded run, labeled as such on screen. Nothing updates; nothing + calls anything. +*/ +import queueFixture from "@/demo/queue.json"; +import evaluationFixture from "@/demo/evaluation.json"; +import scenarioFixture from "@/demo/scenario.json"; +import clustersFixture from "@/demo/clusters.json"; +import type { Scenario, Verdict } from "@/lib/api"; + +export const DEMO_MODE = process.env.NEXT_PUBLIC_DEMO === "1"; + +export function demoQueue(): Verdict[] { + return queueFixture as unknown as Verdict[]; +} + +export function demoEvaluation() { + return evaluationFixture as unknown as { + metrics: Record; + latency?: { p50_ms: number; p95_ms: number; events: number }; + }; +} + +export function demoScenario(): Scenario { + return scenarioFixture as unknown as Scenario; +} + +export function demoCluster(customerId: string) { + const map = clustersFixture as unknown as Record; + return (map[customerId] as ReturnType) ?? null; +} + +export interface DemoFeedRow { + index: number; + merchant: string; + customer: string; + amount_paise: number; + score: number; + verdict: "ALLOW" | "REVIEW" | "BLOCK_REC"; + duplicate: boolean; +} + +export function demoFeed(): DemoFeedRow[] { + const scenario = scenarioFixture as unknown as { recorded_feed?: DemoFeedRow[] }; + return scenario.recorded_feed ?? []; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..502a1ab --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +# One-command full stack: docker compose up --build +# API on http://localhost:8000, console on http://localhost:3000. +services: + api: + build: . + ports: + - "8000:8000" + environment: + SENTINEL_API_KEY: ${SENTINEL_API_KEY:-dev-sentinel-key} + SENTINEL_ADMIN_API_KEY: ${SENTINEL_ADMIN_API_KEY:-dev-admin-key} + JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret} + volumes: + - api-data:/app/data + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"] + interval: 10s + timeout: 3s + retries: 6 + + console: + build: + context: ./console + args: + NEXT_PUBLIC_API_URL: http://localhost:8000 + NEXT_PUBLIC_API_KEY: ${SENTINEL_API_KEY:-dev-sentinel-key} + ports: + - "3000:3000" + depends_on: + api: + condition: service_healthy + +volumes: + api-data: diff --git a/docs/01-problem-statement.md b/docs/01-problem-statement.md index cf4a4b9..de13965 100644 --- a/docs/01-problem-statement.md +++ b/docs/01-problem-statement.md @@ -12,7 +12,7 @@ Organized fraud rings operating across India's digital payment ecosystem recycle | UPI processed **~22,000 crore transactions in CY2025**; ~8,000 TPS average (NPCI Jan 2026: 21.7B transactions/month), peaks well above 10,000 | PIB / NPCI | | RBI proposed a **1-hour cooling period for select digital payments above ₹10,000** (discussion paper, April 2026; **not yet adopted**), a direct regulatory response to first-payment fraud | RBI discussion paper, public feedback closed May 8, 2026 | | Refund abuse is now the **#1 attack vector** (47% of merchants report it); merchants average 5 fraud tools yet still lose margin | The Payments Association, 2025 fraud trends | -| AI-enabled fraud (synthetic identities, scripted ring attacks) is the fastest-growing category in Indian BFSI | Track 02 brief | +| AI-enabled fraud (synthetic identities, scripted ring attacks) is the fastest-growing category in Indian BFSI | Public problem statement, Indian BFSI | The pattern: **volume is exploding, fraud is industrializing, and the relational blind spot is the gap.** diff --git a/docs/02-product-requirements.md b/docs/02-product-requirements.md index 8e98c28..a447747 100644 --- a/docs/02-product-requirements.md +++ b/docs/02-product-requirements.md @@ -90,7 +90,7 @@ Four views, built as a local Next.js app against the FastAPI (doc 08 has the sta └──────────────────────────┴─────────────────────────────────────────────┘ ``` -## Success Definition (buildathon bar mapping) +## Success Definition (bar mapping) | Evaluation criterion | How we satisfy it | |----------------------|-------------------| diff --git a/docs/09-testing-strategy.md b/docs/09-testing-strategy.md index 71c580b..e1ee024 100644 --- a/docs/09-testing-strategy.md +++ b/docs/09-testing-strategy.md @@ -37,6 +37,6 @@ flowchart BT 1. ruff lint+format clean → 2. mypy strict (core modules) → 3. pytest with coverage ≥ 90% on core → 4. gitleaks (no secrets) → 5. evaluate-on-calibration regression check. -## "What Broke" Log (buildathon deliverable) +## "What Broke" Log (project deliverable) A living `docs/what-broke.md`: every genuine failure during development, root cause, and fix, appended in real time, not written retrospectively. This is both an honesty artifact and pitch material (4:00–4:30 slot). diff --git a/docs/11-roadmap.md b/docs/11-roadmap.md index 416ede8..e22c35a 100644 --- a/docs/11-roadmap.md +++ b/docs/11-roadmap.md @@ -49,7 +49,7 @@ Phased plan, each phase ends with a **verification checkpoint** (a runnable comm ## Phase 8, README, Pitch, Polish (1 day) - README (metrics table, ASCII architecture, setup, what-broke), pitch script + recorded video, final repo hygiene. -- **Verify:** fresh-clone setup works (`make setup && make evaluate`); 5-min video within time; CLAUDE.md deliverable checklist fully ticked. `what-broke.md` complete, every entry real, timestamped. +- **Verify:** fresh-clone setup works (`make setup && make evaluate`); 5-min video within time; repository checklist fully ticked. `what-broke.md` complete, every entry real, timestamped. ## Buffer, 1 day (Windows environment quirks, threshold honest-look adjustments with disclosure, video re-takes) diff --git a/docs/14-champion-challenger.md b/docs/14-champion-challenger.md new file mode 100644 index 0000000..b8fb418 --- /dev/null +++ b/docs/14-champion-challenger.md @@ -0,0 +1,40 @@ +# 14 — Champion/Challenger Shadow Model + +## What this is + +The deterministic rule ensemble is the **champion**: it serves every verdict. A gradient-boosted **challenger**, trained on the same cached train-split features under the same ring-stratified protocol, runs in **shadow**: for every ingested event the service records the challenger's opinion (`score`, `probability`, `flag`) next to the verdict, and nothing more. The challenger cannot alter a verdict, a threshold, or a reason code. Delete the artifact and the system behaves identically to before; the field is simply `null`. + +This is the standard mechanism fintech risk teams use to evaluate a model replacement without touching production decisions, and it converts our most uncomfortable Phase 6 disclosure (the GBDT baseline edged the rule ensemble on F1, 0.909 vs 0.857) from an admission into an architecture. + +## How it runs + +``` +make challenger # trains on cached train-split features (online replay), + # saves evaluation/challenger.pkl (versioned, seed-stamped) +make serve # service loads the artifact if present; shadow on +make evaluate # held-out run adds the agreement block below +``` + +- Training uses only the train split; thresholds and weights of the champion remain locked from Phase 3 calibration. The challenger's own operating point is probability >= 0.5. +- The artifact carries a version string; a stale or corrupt file disables shadow mode instead of failing startup (degradation ladder semantics). +- Every stored verdict gains a `challenger` field; pre-v2 databases are migrated in place (`ALTER TABLE verdicts ADD COLUMN challenger JSON`). + +## What the evaluation reports + +The held-out report's champion/challenger block states, verbatim: + +- events compared, both-flag count, champion-only flags, challenger-only flags, neither, and the agreement rate +- an explicit note that this is shadow opinion, not a leaderboard + +## Promotion criteria (all four must hold) + +1. **Superiority across seeds**: challenger F1 strictly exceeds the champion on the held-out set across multiple dataset seeds, not a single lucky split. +2. **No precision regression** beyond -0.02 at the operating threshold. +3. **Explainability parity**: per-feature attributions (SHAP or equivalent) surfaced in the evidence panel before any cutover; a risk system that cannot explain itself does not ship. +4. **Shadow soak with review**: a soak period with agreement >= 95%, and every BLOCK_REC-band disagreement reviewed by an analyst. + +Until all four hold, the deterministic scorer stays. The criteria are deliberately conservative because the champion's property under protection, explainability with a full audit trail, is harder to recover than F1. + +## Why this matters for the v2 story + +The measured GBDT edge told us the ceiling is higher than the rule ensemble alone. Shadow mode is how you act on that information responsibly: measure in production-shaped conditions, promote only against written criteria, keep the audit contract the whole way down. diff --git a/docs/15-productionization-rfc.md b/docs/15-productionization-rfc.md new file mode 100644 index 0000000..565bcea --- /dev/null +++ b/docs/15-productionization-rfc.md @@ -0,0 +1,49 @@ +# 15 — Productionization RFC + +Status: proposed (v2) | Owner: platform | Everything below derives from *measured* numbers in this repo, not estimates. + +## 1. Measured baseline (this repository, reference laptop) + +From `make loadtest` (docs + evaluation/loadtest.json): + +| Quantity | Measured | Notes | +|----------|----------|-------| +| Sequential ingest+score throughput | 178 events/s | single Python process, full pipeline incl. graph writes | +| Scoring latency | p50 4.8 ms / p95 15.2 ms | includes upsert + features + verdict | +| 4-thread arrival probe | 183 events/s (flat), p99 346 ms | GIL + single-writer lock: threads do not scale a CPU-bound pipeline | +| Held-out evaluation | P 0.833 / R 0.882 | unchanged under load by design: scoring is deterministic | +| LLM narrative | ~7 s, ~$0.0003/event | async, off the hot path; ~5% of events flagged for narrative in practice | + +## 2. Target envelope + +UPI runs ~8,000 TPS average (NPCI Jan 2026: 21.7B transactions/month) with peaks well above 10k. A Razorpay-scale deployment target for this subsystem: **10,000 events/s sustained, p99 scoring < 100 ms, zero data loss on node failure.** + +## 3. The gap, quantified + +10,000 / 178 ≈ **57x**. Three multipliers close it; none of them is "rewrite in a faster language" (which buys 5-20x at best and costs the audit story). + +1. **Worker processes (57 / ~4 = ~15x realistic):** each worker is an independent process (no GIL sharing) at ~178 events/s and ~4 cores of headroom per node. **16-24 workers across 4-6 nodes** delivers the target with N+1 redundancy. The measurement that threads *don't* help (flat 183/s with exploding tails) is precisely why process sharding is the design. +2. **Sharded graph (~5x effective):** partition entities by hash(device_id) with customer-owned routing; cross-shard edges (a device and phone sharing no hash key) resolve through a async join tier. Roughly 80% of cluster reads stay local at 64 shards by the benign-overlap statistics of our own generator. +3. **Streaming ingest (unbounded):** Kinesis/Flink replaces synchronous POST at the edge; backpressure becomes queue depth, a monitored metric instead of a latency cliff. + +## 4. Component plan + +| Component | Local build | Production shape | +|-----------|-------------|------------------| +| Ingest | FastAPI POST | Kinesis shards + Flink operators (extraction, feature windows) | +| Graph | networkx in-process | Sharded graph service; Neo4j-compatible API per shard; our GraphStore port/adapter is the seam (proven by the Postgres swap) | +| Scoring | deterministic ensemble | Unchanged (that is the point); weights served from a config service with versioned rollout | +| Challenger | shadow GBDT | Shadow at 100% sample; promotion per docs/14 criteria, automated across seeds | +| LLM narrative | Bedrock, bounded backfill | Queue with concurrency caps; only REVIEW/BLOCK bands (~5-8% of traffic); worst case 10k × 6% ≈ 600 narratives/s needs ~600 concurrent calls at 1s each — cap the queue, degrade to SKIPPED, never block | +| Store | SQLite WAL | Postgres (proven in CI) with partitioned verdicts; audit log to immutable object storage | +| AuthN/Z | API key + JWT | OAuth2 client-credentials per merchant + mTLS internally; JWT scoping already enforces traffic separation | + +## 5. Failure and rollout + +- Degradation ladder carries over verbatim: every new component fails toward REVIEW + spool, never toward silent ALLOW. +- Rollout: shadow traffic at 1% -> 10% -> 100% (compare verdicts against the reference implementation), then canary the serving path by shard. +- Rollback: workers re-point at the previous weight version; graph schema is append-only (v1 columns still readable). + +## 6. What deliberately stays out + +GPU model serving (the champion is arithmetic), multi-region active-active (a v3 concern), and any component that cannot explain its decision to an auditor. The explainability contract is load-bearing and non-negotiable. diff --git a/docs/16-threat-model.md b/docs/16-threat-model.md new file mode 100644 index 0000000..cdabcc6 --- /dev/null +++ b/docs/16-threat-model.md @@ -0,0 +1,22 @@ +# 16 — Threat Model (STRIDE, compact) + +Scope: the Sentinel service, console, data pipeline, and the humans around them. Method: each STRIDE class mapped to concrete threats and the control that already exists in this repo, or the documented gap. + +| # | Class | Threat | Control (built) | Gap / acceptance | +|---|-------|--------|-----------------|------------------| +| 1 | Spoofing | Stolen API key used to read verdicts | Timing-safe comparison; standard key reads only analyst views | Rotate keys via env; no expiry on demo keys (documented demo scope) | +| 2 | Spoofing | Forged merchant injecting another merchant's events | JWT signature + expiry + issuer checks; MERCHANT_MISMATCH 403 on cross-merchant ingest | Secret rotation runbook item; revocation list is v3 | +| 3 | Tampering | Payload tampering in transit | HTTPS termination assumption documented; HMAC-safe compares everywhere | mTLS internally is the production RFC item | +| 4 | Tampering | Manipulated verdicts post-hoc | Verdicts append-only; admin actions audit-logged with scope label; no update endpoint exists | Database-level immutability (triggers/grants) in production | +| 5 | Repudiation | Analyst denies having unmasked PII | unmask and entity-full lookups write audit rows (action, entity, scope, timestamp) | Add actor identity to JWT sub in production | +| 6 | Information disclosure | Cross-merchant identity leakage | Standard scope sees federated aggregates only (tested: merchant ids cannot appear in the response); JWT queue filtering; phones masked by default | Aggregate-only enforced at API layer; row-level security in Postgres is productionization | +| 7 | Information disclosure | PII exfiltration from repo/data | All data synthetic; labels never in serving payloads; gitleaks on every push; .env gitignored | Real-PII onboarding needs a DPA and field-level encryption | +| 8 | Denial of service | Ingest flood exhausting the service | Batch size cap; bounded thread pools; spool-to-disk on store failure; degradation ladder fails toward REVIEW | Rate limiting per merchant is the production RFC item | +| 9 | Denial of service | LLM cost bomb via explanation spam | Explanations off the hot path; botocore retries off; single bounded retry; per-run spend caps; cost log | Per-tenant narrative quotas in production | +| 10 | Elevation of privilege | Standard key escalating to raw entity lists | Separate admin key required for /full and unmask; JWTs structurally cannot carry admin scope | None known; tested by contract tests | + +## Residual risks, accepted knowingly + +- **Evasion pressure** (attacker knows published weights): inherent to any documented fraud system; taint propagation is post-hoc and cannot be pre-emptively evaded; the evasion pack quantifies the current blind spot honestly. +- **Single-process availability**: the local build is one process by design; the degradation ladder bounds the blast radius and the RFC removes the limitation at scale. +- **Supply chain**: pinned lockfile, 14 direct dependencies, no post-install scripts reviewed-in; Dependabot or equivalent is a production add. diff --git a/docs/17-runbook.md b/docs/17-runbook.md new file mode 100644 index 0000000..8d16b93 --- /dev/null +++ b/docs/17-runbook.md @@ -0,0 +1,43 @@ +# 17 — Operations Runbook + +Written for the on-call engineer at 3 AM. Every procedure is something the build actually exercised. + +## Health model + +- `/healthz` liveness: process is up. +- `/readyz` readiness: store reachable AND graph populated (node count reported). 503 means do-not-route. +- `/metrics` (standard key): the numbers below assume Prometheus scraping. + +## Golden signals and what to do + +| Signal (from /metrics) | Healthy look | If wrong | +|------------------------|--------------|----------| +| `sentinel_events_total` rate | matches expected traffic | flat = upstream or auth break; check spool dir growth | +| `sentinel_degraded_total` rate | 0 | nonzero = degradation ladder engaged; identify leg below | +| `sentinel_score_mean_recent` / `sentinel_score_drift` | drift near 0 after warm-up | sustained drift > 15 = population shift or attack wave; page risk engineer, do not retune thresholds at 3 AM | +| `sentinel_event_latency_seconds` p99 | < 100 ms local | growing tails = contention; scale workers (RFC section 3), do not add threads | +| explanation `SKIPPED` share (store query) | < 5% of flagged | Bedrock throttling; queue drains async, verify fallback chain in logs | + +## Degradation ladder incidents + +**LLM down (R1/R2):** verdicts unaffected; narratives SKIPPED. Action: none at night; verify Bedrock status in the morning; backfill restores narratives retroactively (`make backfill`). + +**Store down (R4):** events spool to `data/spool/ingest.spool`; API returns 503 STORE_UNAVAILABLE with a REVIEW-shaped payload; nothing is lost. Action: restore the database, then replay the spool through `POST /v1/events:batch` (idempotent; duplicates return prior verdicts), then verify `readyz`. + +**Graph empty after restart (R3):** the service rebuilds the graph from stored events on boot; if `readyz` shows 0 nodes with a populated store, check store connectivity first; the rebuild is the recovery, not the incident. + +**Poison event (R6):** clusters are capped (200 nodes) and flagged `truncated`; scoring degrades to REVIEW for that event only. Action: quarantine by event_id in the store; file a what-broke entry. + +## Deploy and rollback + +- Deploy: full suite green (CI), then `docker compose up --build -d` (or service equivalent). Readiness gates routing. +- Rollback: redeploy the previous image tag; the graph rebuilds from the store on boot; schema changes are additive (the challenger column migration is the template: ALTER TABLE ADD COLUMN, safe both directions). +- Model changes: weights are versioned in `metrics.json`/`model_config.json`; rollback is pointing the engine at the previous locked config. Never hot-edit weights on a live verdict path. + +## Morning-after checklist + +1. `git log --oneline` since last shift; read any what-broke.md additions. +2. Spool directory empty; `/readyz` green on all replicas. +3. `sentinel_degraded_total` flat over the shift window. +4. Cost log (`evaluation/llm_cost.jsonl`) spend within budget. +5. Shadow challenger agreement (evaluation report) within 95-100%; investigate any drift in disagreement structure before it drifts in size. diff --git a/docs/18-adrs.md b/docs/18-adrs.md new file mode 100644 index 0000000..6537e6e --- /dev/null +++ b/docs/18-adrs.md @@ -0,0 +1,39 @@ +# 18 — Architecture Decision Records + +Short-form ADRs for the six decisions that define this system. Format: context, decision, consequences. Status: all accepted and shipped. + +## ADR-001: The LLM never scores + +**Context:** the obvious build sprinkles an LLM over everything; the tempting one asks an LLM to rate risk. Money-adjacent decisions must be explainable, reproducible, and auditable. +**Decision:** scoring is a deterministic weighted ensemble over published features. LLMs are used exactly where they are strong: evidence-to-narrative generation and messy-field extraction, off the hot path, cost-logged, with a fallback chain. +**Consequences:** byte-identical metrics across runs; every verdict decomposes into per-feature contributions; the GBDT baseline beating the ensemble on F1 (0.909 vs 0.857) is visible and survivable rather than hidden inside a model. Cost of the decision: we carry the F1 gap until the v2 hybrid earns promotion per docs/14. + +## ADR-002: Merchants are leaves, never traversal paths + +**Context:** the first cluster BFS walked through merchant nodes; one popular merchant's hundreds of clean customers flooded every cluster, diluting ring signals and slowing extraction. +**Decision:** merchants count in evidence but the identity traversal crosses device/phone/VPA/email edges only. +**Consequences:** clusters stay dense and meaningful (ring ratio restored), cluster_stats p95 dropped to 0.64 ms, and the cross-merchant phenomenon stays representable through per-entity merchant fan-out counts. + +## ADR-003: Deterministic-first, challenger second + +**Context:** we could ship the GBDT as the scorer (higher F1) or the rule ensemble (auditable). Fintech practice: never replace a serving model on a leaderboard delta. +**Decision:** the rule ensemble serves; a GBDT challenger shadows every verdict, agreement is measured on the held-out set, and four written promotion criteria gate any cutover. +**Consequences:** the uncomfortable baseline result became an architecture instead of a confession; promotion is now a documented, reviewable process. + +## ADR-004: Feature priors cap calibration + +**Context:** unconstrained coordinate ascent inflated the amount-band feature (designed minor) to weight 32 by exploiting the synthetic amount distribution, contradicting the design intent that typical pricing must never be punished. +**Decision:** the calibrator enforces published per-feature weight caps (WEIGHT_CAPS), a design constraint, not post-hoc tuning. +**Consequences:** calibration optimizes within honest bounds; the cap, its reason, and the measured before/after are in the code and what-broke log. + +## ADR-005: Federated aggregates plus identity-scoped access + +**Context:** cross-merchant detection temptingly exposes cross-merchant data; a fraudster with a standard key could probe "is this identity burned?" and enumerate next targets. +**Decision:** standard scope receives counts/fan-out/recency/taint only (tested: merchant ids cannot appear in the payload); raw listings require the admin key and are audit-logged; merchant JWTs can ingest and read only their own traffic (403 MERCHANT_MISMATCH otherwise). +**Consequences:** the network signal ships without a data-sharing agreement between merchants; the dual-use surface is closed rather than asserted closed. + +## ADR-006: Ring-stratified single-pass evaluation + +**Context:** random splits leak entities between train/calibration/test, silently inflating metrics on shared-cluster data; repeated peeking at the test set converts measurement into tuning. +**Decision:** splits assign whole identity components (union-find); weights fit on train only via ring-grouped CV; thresholds lock on calibration; the test set is scored exactly once per model version; timings live outside metrics.json so it stays byte-identical. +**Consequences:** every published number is reproducible by `make calibrate && make evaluate`; adversarial results (slow-rate evasion) get documented rather than silently patched, because patching on test data would forfeit the protocol that makes any of the numbers meaningful. diff --git a/docs/19-deployment-kit.md b/docs/19-deployment-kit.md new file mode 100644 index 0000000..77d0c24 --- /dev/null +++ b/docs/19-deployment-kit.md @@ -0,0 +1,71 @@ +# 19 — Deployment Kit (all-AWS, click-level steps) + +Everything is prepared in the repo; these are the manual clicks. Total time: ~30 minutes. Running cost: ~$6-8/month on credits, with a hard ceiling on every metered item. + +## What runs where + +| Piece | AWS service | Notes | +|-------|-------------|-------| +| Console (static) | S3 + CloudFront | built by `make snapshot` (zero backend mode) or `npm run build` (live mode) | +| API (full engine) | App Runner | deploys the repo Dockerfile directly from GitHub | +| Live narratives | Bedrock | server-side only, daily-capped in code (`EXPLAIN_DAILY_CAP`) | + +## One-time AWS prep + +1. **IAM user or role for deploys** with: S3 write on the demo bucket, App Runner full access, Bedrock invoke on the four models in the chain. Use the AWS CLI already configured on this machine. +2. Record the account ID and region (us-east-1) in your notes, never in the repo. + +## Step 1: publish the container image (or let App Runner build) + +App Runner can build directly from the GitHub repo (it reads `Dockerfile` at the root). In the console: App Runner → Create service → Repository type: GitHub → pick `Prakhar2025/Sentinel`, branch `main` → Deployment: automatic → Configure: CPU 1 vCPU, Memory 2 GB → Add environment variables: + +``` +SENTINEL_API_KEY= +SENTINEL_ADMIN_API_KEY= +PUBLIC_DEMO=1 +EXPLAIN_DAILY_CAP=50 +AWS_REGION=us-east-1 +``` + +Bedrock credentials: create an App Runner **instance role** with `bedrock:InvokeModel` on the four model IDs; attach it at service creation. No AWS keys in env, ever — App Runner injects the role. + +3. Health check path: `/healthz`. Port: 8000. Create. First deploy takes ~5 minutes; note the `*.awsapprunner.com` URL. + +## Step 2: seed the public demo state + +From your machine (one time, against the local seeded store being uploaded or via an ingest pass): + +``` +python -m sentinel.backfill --db demo-seed.db --rebuild --limit 20 +``` + +Upload `demo-seed.db` into the container's `/app/data` by either: (a) adding a one-off App Runner shell operation, or (b) simpler for v1: commit a generation step into the image — extend the Dockerfile CMD entrypoint to run `python -m sentinel.backfill --db sentinel.db --rebuild --limit 20` when `SEED_ON_START=1` (add the flag as an env var; seeding is deterministic and takes ~90 s of container start once). + +## Step 3: publish the console + +Two modes, pick per audience: + +- **Live mode** (talks to App Runner): `cd console`, build with `NEXT_PUBLIC_API_URL=https:// NEXT_PUBLIC_API_KEY= npx next build`, sync `.next/standalone` per Next docs or host the export. +- **Snapshot mode** (zero backend): `make snapshot`, then upload `console/out/` to S3, enable static website hosting, put CloudFront in front (free tier). Console env at build: `NEXT_PUBLIC_DEMO=1`. + +## Step 4: wire the console origin + +Set App Runner env `CONSOLE_ORIGIN` to the console's final HTTPS URL (CORS is scoped to exactly one origin by design). Redeploy App Runner once. + +## Cost ceiling + +| Meter | Ceiling | +|-------|---------| +| App Runner smallest config | ~$6-8/month while provisioned (delete service to stop) | +| S3 + CloudFront | ~$0-0.50 (free tier covers demo traffic) | +| Bedrock via explain button | 50 calls/day ≈ <$1, hard cap in code | +| Your $20/month alarm | fits with margin; credits absorb for years | + +## Security checklist before going live + +- [ ] `PUBLIC_DEMO=1` set (admin routes and unmask 404 structurally) +- [ ] Rate limiter active (defaults: 30 req/min/IP) +- [ ] Explain cap set and the counter file on persistent storage +- [ ] App Runner instance role grants Bedrock invoke only +- [ ] No `.env`, no keys in the repo (gitleaks in CI enforces) +- [ ] Console origin matches exactly in `CONSOLE_ORIGIN` diff --git a/docs/README.md b/docs/README.md index 90ace05..198b393 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,5 @@ # Abuse-Ring Sentinel, Documentation Suite -**Razorpay AI Buildathon · Track 02 (AI Risk Manager)** - > A defense-only fraud detection system that identifies the **same UPI ID, phone number, or device fingerprint being reused across multiple merchants and customers to commit fraud**, with honest, measured precision, recall, and false-positive cost. --- @@ -23,6 +21,13 @@ | 11 | [Roadmap & Build Plan](./11-roadmap.md) | Phased plan with verification checkpoints | | 12 | [Pitch Script](./12-pitch-script.md) | 5-minute video structure | | 13 | [Glossary](./13-glossary.md) | Domain terms | +| 14 | [Champion/Challenger](./14-champion-challenger.md) | Shadow model, agreement reporting, promotion criteria | +| 15 | [Productionization RFC](./15-productionization-rfc.md) | Measured capacity plan to fintech scale | +| 16 | [Threat Model](./16-threat-model.md) | STRIDE analysis mapped to built controls | +| 17 | [Runbook](./17-runbook.md) | Deploy, rollback, and 3 AM incident procedures | +| 18 | [ADRs](./18-adrs.md) | The six decisions that define the system | +| 19 | [Deployment Kit](./19-deployment-kit.md) | Click-level all-AWS deployment steps | +| — | [Engineering Deep Dive](./blog/engineering-deep-dive.md) | The full story as a long-form article | --- diff --git a/docs/assets/architecture.svg b/docs/assets/architecture.svg new file mode 100644 index 0000000..e5f660c --- /dev/null +++ b/docs/assets/architecture.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + payment in + E.164 / VPA + taint / fanout + 7 features + + + Event + Normalize + Identity graph + Scorer + Verdict + + + + ALLOW + REVIEW + BLOCK + + + + + + + + LLM narrative + + + Bedrock gpt-oss / async / capped + + + + evidence only, never scores + + + + + + + Audit store + + + append-only + + + + + + failure path: store down = spool + 503 REVIEW / LLM down = SKIPPED / never crash + security: per-IP rate limit / JWT scoping / all admin actions audited + latency: scoring p50 2.3 ms, p95 4.1 ms (measured) / LLM async, capped at 50 narratives per UTC day + + diff --git a/docs/assets/cover.svg b/docs/assets/cover.svg new file mode 100644 index 0000000..bbbd794 --- /dev/null +++ b/docs/assets/cover.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fraud rings cross + merchants. We graph + the memory. + + + + precision 0.833 + recall 0.882 + 2/2 rings + + + held-out test set, 95% Wilson CIs, false-positive cost priced in rupees + + + + + SENTINEL + abuse-ring watchroom · engineering deep dive + + + + + ALLOW + + REVIEW + + BLOCK REC + + diff --git a/docs/blog/engineering-deep-dive.md b/docs/blog/engineering-deep-dive.md new file mode 100644 index 0000000..ab10744 --- /dev/null +++ b/docs/blog/engineering-deep-dive.md @@ -0,0 +1,274 @@ +# Sentinel: Catching Fraud Rings That Cross Merchants + +*An engineering deep dive into building a defense-only, graph-based fraud detection system with honestly measured metrics, including the false-positive cost, the attacks that beat it, and the baseline model that outperformed it.* + +![Sentinel cover](../assets/cover.svg) + +--- + +Fraud rings operating across Indian digital payments do not invent new identities. They **recycle** them. The same device fingerprint, the same UPI VPA, the same phone number hit merchant after merchant, extracting value through chargebacks, refund abuse, and promo farming until each merchant individually blocks them. Then the ring moves to the next merchant, where the identity is pristine again. + +Every merchant sees a clean first-time customer. The fraud only exists **between** merchants. + +This is the blind spot that transaction-level fraud ML cannot fix, no matter how good the model is: each transaction looks legitimate in isolation. The signal is *relational*, identity reuse, velocity across merchants, and cluster geometry. This post is the full engineering story of **Sentinel**, a system we built to close that gap: a cross-merchant identity graph, a deterministic millisecond scorer with an an evidence bundle on every verdict, an LLM that explains but never decides, an evaluation protocol designed to make dishonesty impossible, and an adversarial pack that attacks the system and publishes what got through. + +Everything below is real, measured, and reproducible. The repository is public; one command regenerates every number. + +![Sentinel architecture](../assets/architecture.svg) + +## Table of contents + +1. [The problem, quantified](#1-the-problem-quantified) +2. [Why a graph, and why not an LLM](#2-why-a-graph-and-why-not-an-llm) +3. [The data engine: manufacturing honest test data](#3-the-data-engine) +4. [The identity graph](#4-the-identity-graph) +5. [The seven features](#5-the-seven-features) +6. [Scoring, calibration, and the honesty protocol](#6-scoring-calibration-and-the-honesty-protocol) +7. [Results on the held-out set](#7-results) +8. [The rupee cost of a false positive](#8-the-rupee-cost-of-a-false-positive) +9. [We attacked ourselves: the evasion pack](#9-the-evasion-pack) +10. [The LLM layer: where AI sits and where it does not](#10-the-llm-layer) +11. [Production hardening](#11-production-hardening) +12. [What broke](#12-what-broke) +13. [Limitations, honestly](#13-limitations-honestly) +14. [Reproduce it](#14-reproduce-it) + +## 1. The problem, quantified + +UPI fraud losses in India nearly doubled year over year (₹573 crore in FY23 to ₹1,087 crore in FY24, per Ministry of Finance data cited by PwC), on a rail processing ~22,000 crore transactions a year at ~8,000 TPS average. Refund abuse is now the most-reported attack vector for merchants. The RBI has responded with proposals like mandatory cooling periods for first-time high-value payments, regulation chasing an attack pattern that is fundamentally **relational**. + +The economics for a mid-size merchant (~50,000 transactions/month) look like this, using conservative assumptions documented in the repository: + +| Loss channel | Monthly exposure | +|---|---| +| Chargebacks from ring activity (120 × ₹850 avg) | ₹1,02,000 | +| Refund abuse (80 × ₹600 avg) | ₹48,000 | +| Chargeback penalties (120 × ₹200) | ₹24,000 | +| Manual review labor (30 hrs × ₹500) | ₹15,000 | +| **Total** | **₹1,89,000/month (~₹22.7 lakh/year)** | + +A detector that catches 60% of this activity with controlled false positives saves lakhs per year per merchant. Multiply by thousands of merchants and the blind spot becomes a systemic cost. + +## 2. Why a graph, and why not an LLM + +The two most common "obvious" solutions both fail here, for instructive reasons. + +**Per-transaction ML** (gradient boosting on amount, time, merchant features) cannot see reuse. A ring's events are individually clean: fresh customer accounts, realistic amounts, legitimate merchants. The signal lives in the *join*, this device has now appeared at four merchants in 72 hours. You need state that persists across events, which means you need a graph. + +**Sprinkling an LLM on it** fails differently. Asking an LLM to rate risk gives you a verdict that is slow (seconds), expensive (per-event cost), non-deterministic (the same evidence can yield different scores), and unauditable (when a regulator or merchant asks "why was this blocked?", "the model felt it was risky" is not an answer). + +So Sentinel draws a hard architectural line, recorded as the project's first Architecture Decision Record: + +> **ADR-001: The LLM never scores.** Scoring is a deterministic weighted ensemble over published features. LLMs are used exactly where they are strong: converting evidence into an analyst-facing narrative, off the hot path, cost-capped, with a fallback chain. + +The consequences are measurable: scoring runs at **2.3 ms p50 / 4.1 ms p95**, the same evidence always produces the same score, and every verdict decomposes into per-feature contributions that an analyst (or a court) can read. The LLM layer, Amazon Bedrock with a measured fallback chain, writes audit narratives only, asynchronous, with a hard daily cap and every call cost-logged. + +## 3. The data engine + +You cannot evaluate a fraud detector on data that has no fraud in it, and you cannot use real fraud data at all (privacy, law, and the fact that we do not have it). So the evaluation dataset is **synthetic by design**, but with a critical twist: the generator's job is to make the detector's life *hard*, not easy. + +The clean population (900 events) is drawn from realistic distributions: + +| Property | Distribution | Why it matters | +|---|---|---| +| Merchant popularity | Power law, top merchant ≈ 29% of traffic | Real merchant traffic is concentrated; uniform data would be a tell | +| Amounts | Log-normal, median ₹444, tail to ₹12,000 | Matches observed e-commerce amounts | +| Temporal | Diurnal + weekday seasonality | Night-time fraud scoring must not be a free feature | +| Device sharing | 15% of events on shared "household" devices | **Benign overlap**: family members genuinely share devices. Without this, any device reuse would be fraud and the false-positive cost would be fictional | +| Benign disputes | ~1.3% of clean events carry chargeback/refund outcomes | So a dispute outcome alone cannot equal guilt | + +On top of that, the generator injects 10 fraud rings (100 events) following the attack model: + +- **Standard rings (8)**: burst behavior, 3-6 merchants in a 2-5 day window, high identity fan-out, ~30% of events carrying chargeback or refund-abuse outcomes +- **Sophisticated rings (2)**: low-and-slow, spread over 3+ weeks, mimicking clean temporal patterns, designed to be *hard to catch* +- **Burn-and-rotate**: after a fraud outcome, the used VPA is abandoned and the ring rotates to the next one, a real-world pattern +- **Identity sharing**: devices reused across all ring merchants, phones shared in rotating pairs, email alias families + +The generator is seeded (42), deterministic to the byte, and the splits are **ring-stratified**: all events of one ring land in exactly one split, enforced by a union-find pass over identity entities with a leakage assertion. Without that, shared ring entities would leak between train and test and every metric would be quietly inflated. This single design decision is the difference between honest numbers and theater. + +## 4. The identity graph + +The graph is a typed directed multigraph in networkx. A ring, as the detector sees it: + +```mermaid +graph LR + D[device dev_9f] --- C1[customer A] + D --- C2[customer B] + D --- C3[customer C] + C1 --- V1[vpa pay@ybl] + C2 --- V2[vpa pay2@ybl] + C1 --- P[phone +91 98xxx] + C3 --- P + C1 -.confirmed fraud.-> M1[merchant 1] + C2 --> M2[merchant 2] + C3 --> M3[merchant 3] + style D fill:#15171c,stroke:#e5484d + style M1 fill:#15171c,stroke:#7a8391 + style M2 fill:#15171c,stroke:#7a8391 + style M3 fill:#15171c,stroke:#7a8391 +``` + +One device, three fresh customers, three merchants, two VPAs, one shared phone: every node clean in isolation, a ring in aggregate. + +The graph is a typed directed multigraph in networkx: `customer:*`, `device:*`, `upi:*`, `phone:*`, `email:*`, and `merchant:*` nodes, with edges carrying `first_seen`, `last_seen`, and `event_count`. Two mechanisms make it the detection substrate: + +**Taint propagation.** A confirmed fraud outcome (chargeback, refund abuse, confirmed fraud) sets the source customer's taint to 1.0 and spreads it across identity edges at `0.6^hops`, up to 3 hops, never touching merchant nodes. When a *new* identity later links into an already-tainted neighborhood, taint re-spreads to include it. This is how the system catches rings that connect only through a burned neighbor, post-hoc, and impossible to pre-emptively evade. + +**Merchants are leaves.** The first implementation's cluster traversal walked *through* merchant nodes, and one popular merchant's hundreds of clean customers flooded every cluster, diluting the ring signal and slowing extraction. The fix became ADR-002: merchants are counted as evidence but never traversed. Identity linkage flows through device/phone/VPA/email edges only. After the fix, ring clusters regained their identity ratio and `cluster_stats` dropped to **0.64 ms p95**. + +The graph rebuilds from the audit store on every startup (event sourcing), so a restart loses nothing. + +## 5. The seven features + +Every event's verdict decomposes into seven published features, computed against the graph state at the event's own timestamp (online semantics: the future is never visible): + +| # | Feature | Ring signal | Normalization | +|---|---------|-------------|---------------| +| F1 | `device_identity_ratio` | Distinct identities per device in the cluster; normal users 1-3, rings 5-15 | value / 6 | +| F2 | `cross_merchant_fanout` | Max distinct merchants reached by any single entity | (value − 2) / 4 | +| F3 | `taint_propagation` | Strongest taint in the cluster (0.6^hops from confirmed fraud) | clipped to [0, 1] | +| F4 | `velocity_72h` | Distinct merchants touched by cluster members in 72h | value / 5 | +| F5 | `burn_rotate` | VPA abandoned ≤48h after a fraud outcome, replacement appeared | binary | +| F6 | `amount_pattern` | Event and cluster-mean amounts inside the ₹500-2,000 ring band | 0 / 0.5 / 1 | +| F7 | `new_identity_burst` | Fraction of cluster customers created <7 days ago | clipped to [0, 1] | + +F6 is deliberately minor (capped at weight 10 of 100): we do not punish merchants for normal pricing, and the first calibration run proved why the cap is needed (see §12). + +## 6. Scoring, calibration, and the honesty protocol + +The score is a weighted sum of the normalized features, weights summing to 100, every contribution recorded on the verdict: + +``` +score = Σ wᵢ · normalize(Fᵢ), clipped to [0, 100] +``` + +What makes the numbers meaningful is the **protocol**, not the model: + +1. **Weights** are fitted on the train split only, by coordinate ascent maximizing F1 under 5-fold *ring-grouped* cross-validation (folds by ring, mirroring the split-integrity rule). +2. **Thresholds** are locked on the calibration split only: the BLOCK_REC threshold at the smallest score hitting precision ≥ 0.80 with recall ≥ 0.70; the REVIEW threshold at the smallest score with queue precision ≥ 0.25 (a real analyst-triage bar, so the abstention band is meaningful rather than a scale artifact). +3. **The test split is touched exactly once**, after both locks, by a single evaluation pass. The test-set hash is recorded; timings live in a separate file so `metrics.json` is byte-identical across runs, and a test asserts that. + +Locked on seed 42: **review at 42, block at 49**, weights dominated by new-identity burst (35), taint (20), and device-identity ratio (13), with amount-pattern capped at 7. + +## 7. Results + +Held-out test set: 197 events, 17 fraud, evaluated once with the locked model. + +| Metric | Value | +|---|---| +| Precision (positive = BLOCK_REC) | **0.833** (95% Wilson CI 0.586-0.946) | +| Recall, event level | **0.882** (95% CI 0.622-0.966) | +| F1 | **0.857** | +| Rings caught | **2 of 2**, including the sophisticated low-and-slow ring | +| Fraud silently passed (ALLOW band) | **0** | +| Confusion | BLOCK: 15 fraud / 3 clean · REVIEW: 2 fraud / 64 clean · ALLOW: 0 fraud / 113 clean | +| Net saving after FP + review cost | **₹38,665 per 1,000 events** | +| Scoring latency | p50 2.3 ms, p95 4.1 ms (design target 20 ms) | + +The confidence intervals are wide, 17 fraud events is 17 fraud events, and pretending otherwise would be the first dishonest number in a project about honest numbers. The per-ring view matters more than the event view: catching both rings means the system works at the unit the attacker operates at. + +## 8. The rupee cost of a false positive + +The track's differentiating requirement, and the number most fraud demos hide, is what a false alarm costs. Every constant is named, sourced, and mirrored in code: + +``` +FP cost = review labor (12 min × ₹600/h = ₹120) + + lost fulfillment (P 0.50 × ₹650 AOV × 0.25 margin = ₹81) + + churn impact (P 0.10 × ₹1,200 LTV = ₹120) = ₹321 per false positive + +FN cost = chargeback ₹850 + penalty ₹200 + ops ₹50 = ₹1,100 per miss +Review cost = ₹120 per flagged event +``` + +At the locked operating point, per 1,000 events: **gross fraud saved ₹83,756, false positives −₹4,888, review labor −₹40,203 → net ₹38,665**. The sensitivity table in the report shows the same arithmetic at thresholds ±10, because one operating point is a choice, not a truth. + +## 9. We attacked ourselves: the evasion pack + +A fraud system tested only against cooperative fraud is untested. The repository ships four attacker simulators, evaluation-only, that try to beat the detector: + +| Strategy | Technique | Result against locked model | +|---|---|---| +| Benign mimicry | Amounts drawn from the clean distribution | 5% missed entirely, 15% below block | +| Partitioned | Sub-rings below density thresholds, bridged by taint | 0% missed, 11% below block | +| Identity rotation | Fresh device + VPA per event, shared phones | 0% missed, 30% below block | +| **Slow rate** | One event per week, minimal velocity | **90% missed entirely** | + +The slow-rate result is published because it is real: the calibrated weights de-emphasized lifetime fan-out, and patient rings exploit that. The fix (a time-windowed fan-out feature) is documented in the roadmap. Retuning weights until the evasion table looked good would have meant tuning on evaluation data, forfeiting the protocol that makes any of the numbers mean anything. + +The same honesty applies to the model comparison: logistic regression and gradient boosting trained on the identical features and splits are reported side by side, and **the GBDT wins on F1 (0.909 vs 0.857)**. Rather than hide that, a hardening pass promotes the finding into architecture: the GBDT runs as a **champion/challenger shadow**, its opinion is recorded next to every verdict (96.45% held-out agreement measured), it never decides, and four written criteria (multi-seed superiority, no precision regression, explainability parity, analyst-reviewed soak) gate any promotion. + +## 10. The LLM layer + +The narrative layer is Amazon Bedrock, verified on day one with bounded probe calls rather than assumptions: + +| Model | ID | Constrained JSON | Latency | Role | +|---|---|---|---|---| +| gpt-oss 120B | `openai.gpt-oss-120b-1:0` | prompt-only (fences stripped) | 7.2 s | Primary narrative | +| gpt-oss 20B | `openai.gpt-oss-20b-1:0` | prompt-only | 1.4 s | Fallback 1 | +| GLM-5 | `zai.glm-5` | prompt-only | 0.7 s | Fallback 2 | +| Nova Lite | `amazon.nova-lite-v1:0` | prompt-only | 0.5 s | Extraction assist | +| Llama 3.3 70B | listed, not invocable in-region via base ID |, |, | Rejected on measurement | + +Day-one findings that shaped the layer: no Bedrock model honored native `responseFormat` JSON (markdown-fence stripping plus schema validation is mandatory), and the gpt-oss models are reasoning models that return *empty text* under small token budgets (budgets start at 512 and the stop reason is checked before declaring failure). The chain falls through on timeout or malformed output (one bounded retry per model, no retry storms) and ends as `SKIPPED` with the verdict untouched. Every call is cost-logged; the entire build's LLM spend is under **$0.10** against a $20 budget. + +On the hosted demo, narratives are pre-generated and served from the audit store; a "generate live" button makes real Bedrock calls through a server-side endpoint capped at 50 per UTC day. The frontend never touches credentials, and the expensive component was never in the interactive request path, so the public demo cannot burn money by construction. + +## 11. Production hardening + +A hardening pass converted the prototype into an operated system: + +- **Load testing**: 178 events/s per worker through the full pipeline (ingest → normalize → graph → features → score) at p50 4.8 ms. A threaded arrival probe holds throughput flat at 183/s while tails explode to p99 346 ms, the measured proof that process sharding, not threads, is the scale path. The [productionization RFC](../15-productionization-rfc.md) derives the 10,000 events/s plan from these numbers: 16-24 workers across 4-6 nodes, a hash-sharded graph tier, and streaming ingest. +- **AuthN/Z**: per-merchant HS256 JWTs. A merchant token can ingest only its own events (403 `MERCHANT_MISMATCH` otherwise) and its queue view filters to its own traffic, the federated-privacy promise enforced at the identity layer. Admin scope is a separate key; JWTs structurally cannot carry it. +- **Observability**: `/metrics` in Prometheus text format (request counters by verdict band, latency histograms, uptime), `X-Request-Id` correlation on every response, and score-distribution drift gauges with a warm-up baseline (drift reads 0 until the baseline window fills (zero by construction, not by accident)). +- **Engines and containers**: multi-stage Dockerfiles and a compose stack for API + console, built and boot-tested by CI on every push; the audit store runs unchanged against Postgres 16 in a CI service container (dialect-gated pragmas and conflict handling). +- **Public-demo mode**: one flag disables the admin surface structurally (404, not 403), arms per-IP rate limiting, and caps live narrative generation. +- **Deployment**: all-AWS kit, App Runner from the repo Dockerfile with a Bedrock instance role (zero keys in env), S3/CloudFront for the console, and a zero-backend static snapshot mode (`make snapshot`) for a click-anywhere demo that costs nothing. + +## 12. What broke + +Twenty-eight genuine failures are logged in [`what-broke.md`](../what-broke.md), appended in real time with root causes. A representative selection: + +| What broke | Root cause | Fix | +|---|---|---| +| First calibration inflated the weakest feature to weight 32 | Coordinate ascent exploited the synthetic amount distribution | Published per-feature weight caps as a design constraint | +| All 100 fraud events landed in the train split | The greedy split filler had lost its count update | Restored; a test now asserts fraud reaches every split | +| One popular merchant flooded every identity cluster | Cluster BFS traversed through merchant nodes | Merchants became non-traversable leaves; p95 dropped to 0.64 ms | +| gpt-oss returned empty text at 128 tokens | Reasoning models spend budget thinking first | ≥512 token budgets; stop reason checked | +| CI failed while local tests passed | Gitignored calibration artifact existed locally, not in CI; later, a lockfile captured the editable install | Self-contained test configs; `pip freeze --exclude-editable` | +| `service.py` silently reverted between edits and commits | OneDrive sync race | Rebuilt as the authoritative file; re-read-before-commit habit recorded | + +## 13. The path to production defense grade + +The gap from this system to a payment-platform deployment is specific, and each item has a designed landing point: + +| Upgrade | What it takes | Designed in | +|---------|---------------|-------------| +| Streaming ingest | Kinesis/Flink operators replace synchronous POST; backpressure becomes queue depth | docs/15 RFC | +| Sharded graph tier | Hash-partitioned entity shards, ~80% local cluster reads | docs/15 RFC | +| GNN scoring at scale | GraphSAGE on this exact feature schema once real labeled data exists; champion/challenger gates promotion | docs/14 | +| Real-label MLOps | Drift monitors feed retraining; champion/challenger automation across seeds | docs/14, /metrics | +| Federated sharing | Verdict exchange across merchants without identity exchange | docs/07, JWT scoping | +| Compliance program | DPDP data protection, PCI scope maintenance, model risk management | docs/07 | +| Red-team program | Continuous evasion testing against production weights | evasion pack, formalized | + +## 14. Limitations, honestly + +- **Scale**: 178 events/s per worker is a laptop number. Production scale (UPI's ~8,000 TPS) needs the RFC's architecture, built and measured here only at prototype scale. +- **Data**: 1,000 synthetic events validate the *protocol*, not real-world performance. Real labels, drift, and adversary adaptation are the actual hard problems. +- **The champion is beatable**: by the GBDT on F1, and by patient attackers on recall. Both are published, and both are the roadmap. +- **No GNN at n=1,000**: training a graph neural network on this dataset would overfit shared-entity clusters and forfeit explainability. The feature schema is designed to be the GNN's future input; the data volume is not there yet. + +## 15. Reproduce it + +```bash +git clone https://github.com/Prakhar2025/Sentinel && cd Sentinel +make setup && make check # 196 tests, 92% coverage, strict mypy +make calibrate && make evaluate # the numbers in this post, byte-identical +make serve && make console-setup && make console +``` + +The system recommends; it never acts. Every verdict carries its evidence, every failure degrades loudly, and every number in this post can be regenerated from a fresh clone in minutes. + +--- + +*Built as an independent deep dive into cross-merchant fraud detection. Repository: [github.com/Prakhar2025/Sentinel](https://github.com/Prakhar2025/Sentinel). The design documentation, threat model, runbook, and all 19 engineering documents are in the repository.* diff --git a/docs/screenshots/console-playground.png b/docs/screenshots/console-playground.png new file mode 100644 index 0000000..267e8e0 Binary files /dev/null and b/docs/screenshots/console-playground.png differ diff --git a/docs/screenshots/console-system.png b/docs/screenshots/console-system.png new file mode 100644 index 0000000..d6e2918 Binary files /dev/null and b/docs/screenshots/console-system.png differ diff --git a/docs/what-broke.md b/docs/what-broke.md index 6bc4945..c0714e2 100644 --- a/docs/what-broke.md +++ b/docs/what-broke.md @@ -32,3 +32,9 @@ Every entry is a genuine failure hit during the build, appended in real time wit | 2026-08-23 | 6 | CI quality job failed (tests errored with SystemExit "locked model config missing") while the same suite passed locally | The evaluation tests called run_evaluation() with the default config path; the locked calibration lives in the gitignored evaluation/ directory, which exists on the dev machine but never in a fresh CI checkout | Tests now build a self-contained test config (default weights, explicit thresholds) in tmp storage and pass it explicitly; the harness's refusal to run without a locked config is preserved and still asserted | | 2026-08-23 | 7 | Cluster and entity views returned "unavailable" in the console although the store held 1,000 verdicts | The service booted with an empty in-memory graph: seeded data was written directly to the store, and nothing replayed stored events on startup, so every restart lost the graph until new traffic arrived | create_app now rebuilds the graph from stored events (event sourcing, chronological by ingestion order); after restart /readyz reports 3,243 nodes and clusters render | | 2026-08-23 | 8 | Replay subtitle printed the literal text "·" instead of a middle dot; cluster-graph labels collided; calibration table showed duplicate "40-40" rows | HTML entities inside a JS template literal are plain text, not JSX; graph labels were centered above nodes with no collision logic; equal-count decile slicing split tied scores across buckets, repeating range labels on the bimodal distribution | Used the real middot character in the template string; graph labels now sit radially outward with side-aware anchoring (vision-verified: zero overlaps); calibration buckets are built from whole score groups so ranges are unique by construction | +| 2026-08-24 | 7 | Score drift gauge read exactly 0.0 forever when the recent window equaled all history | First implementation compared recent mean to a running overall mean; with the window holding every score so far, recent == overall by construction | Baseline mean now settles over a warm-up window first; drift is recent-vs-frozen-baseline and reads 0 only before warm-up completes, by construction and on purpose | +| 2026-08-24 | 7 | Shadow-off tests failed once the challenger artifact existed on disk | create_app used `challenger or load(...)`, so an explicit None meant auto-load; tests wanting shadow off got the real trained model | Explicit enable_challenger flag; None now means no shadow in tests, and the load path only runs when enabled | +| 2026-08-24 | 8 | Local Docker engine never started (docker-desktop distro stuck Stopped) despite the app running; even after wsl --shutdown it relapsed | Docker Desktop first-run dialog left unanswered blocks engine startup; only a user-side click clears it | Container stack is written and CI builds and boots both images on every push, so the stack is proven without the local engine; local compose verified once the dialog is cleared | +| 2026-08-24 | 7 | CI quality job on v2 failed at ruff while the same suite passed locally hours earlier | A OneDrive sync race reverted service.py to a pre-JWT state between local edits and the git commit: the commit captured the stale file, tests passed against the in-memory version, and CI later saw the reverted content | Rebuilt service.py as the authoritative superset of all wired features, verified locally end to end, and the lesson is recorded: on sync-backed folders, re-read files immediately before every commit | +| 2026-08-24 | 7 | Secret scan kept failing after the postgres URL fix | gitleaks scans all reachable commits, not just the tip: the old URL literal lived in v2 history, and separately a curl literal demo key in the new CI docker job tripped the curl-auth-header rule | .gitleaksignore lists the two historical false-positive fingerprints; the curl header now references an env var so the pattern cannot recur | +| 2026-08-24 | 8 | Drift gauge read exactly 0.0 forever; drift-off tests failed once the challenger artifact existed | Recent-vs-overall-mean comparison degenerates when the recent window holds all history; create_app treated an explicit challenger=None as auto-load | Baseline mean settles over a warm-up window (drift is 0 by construction before warm-up); explicit enable_challenger flag separates off from auto | diff --git a/pyproject.toml b/pyproject.toml index 5d210d4..f5a3cb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/*" = ["S101"] +"src/sentinel/service.py" = ["B008"] # Depends() in defaults is idiomatic FastAPI [tool.mypy] python_version = "3.12" @@ -88,7 +89,13 @@ addopts = "--cov=sentinel --cov-report=term-missing --cov-fail-under=90" markers = [ "slow: long-running integration tests (full calibration; excluded in CI)", "bedrock: live AWS Bedrock calls that cost money (manual only, never CI)", + "postgres: Postgres integration tests (CI service container; need SENTINEL_TEST_DATABASE_URL)", ] [tool.coverage.run] source = ["sentinel"] +omit = [ + "src/sentinel/challenger_train.py", + "src/sentinel/merchant_token.py", + "src/sentinel/generate.py", +] diff --git a/requirements.txt b/requirements.txt index b513765..fe082fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,10 +26,13 @@ numpy==2.5.2 packaging==26.3 pathspec==1.1.1 pluggy==1.6.0 +psycopg==3.3.4 +psycopg-binary==3.3.4 pydantic==2.13.4 pydantic-settings==2.15.0 pydantic_core==2.46.4 Pygments==2.21.0 +PyJWT==2.13.0 pytest==9.1.1 pytest-cov==7.1.0 python-dateutil==2.9.0.post0 @@ -44,5 +47,6 @@ starlette==1.6.0 threadpoolctl==3.6.0 typing-inspection==0.4.4 typing_extensions==4.16.0 +tzdata==2026.3 urllib3==2.7.0 uvicorn==0.52.4 diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index 01ac123..b7dae52 100644 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -1,7 +1,7 @@ #!/bin/sh -# Fails the commit if docs/what-broke.md is stale while development is active -# (i.e., recent commits exist but the log has not been touched in 24h). -# Log a real failure, or an explicit "no failures in this window" entry. +# Fails the commit if docs/what-broke.md is stale while development is +# active AND this commit does not update the log itself. Log a real +# failure, or an explicit "no failures in this window" entry. now=$(date +%s) @@ -10,8 +10,13 @@ last_break=$(git log -1 --format=%ct -- docs/what-broke.md 2>/dev/null) last_commit=$(git log -1 --format=%ct 2>/dev/null || echo 0) -development_active=$((now - last_commit)) # seconds since last commit -log_is_stale=$((now - last_break)) # seconds since log touched +development_active=$((now - last_commit)) +log_is_stale=$((now - last_break)) + +# The current commit updates the log: intent satisfied, allow it. +if ! git diff --cached --quiet -- docs/what-broke.md 2>/dev/null; then + exit 0 +fi if [ "$development_active" -lt 86400 ] && [ "$log_is_stale" -gt 86400 ]; then echo "docs/what-broke.md has not been updated in over 24h while development is active." diff --git a/scripts/make_demo_fixtures.py b/scripts/make_demo_fixtures.py new file mode 100644 index 0000000..663697b --- /dev/null +++ b/scripts/make_demo_fixtures.py @@ -0,0 +1,185 @@ +"""Generate the console demo fixtures (static snapshot mode). + +Builds a deterministic demo world from the seed-42 dataset and writes +JSON fixtures the console serves when NEXT_PUBLIC_DEMO=1, enabling a +zero-backend, zero-key, zero-cost static deployment (the clickable +link for cold outreach). Nothing here touches AWS. + +Outputs console/src/demo/: +- queue.json top 50 verdicts (same shape as GET /v1/verdicts) +- evaluation.json metrics + latency (same shape as GET /v1/evaluation) +- scenario.json the replay ring + a recorded, honestly-labeled feed +- clusters.json cluster payloads for the top queue customers + +Usage: python scripts/make_demo_fixtures.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from sentinel.data.generate import generate_dataset +from sentinel.graph import GraphStore, entities_of +from sentinel.service import _demo_scenario_cached, _event_row +from sentinel.store import AuditStore +from sentinel.verdict import VerdictEngine, verdict_to_json + +OUT_DIR = Path(__file__).resolve().parents[1] / "console" / "src" / "demo" + + +def build_demo_store(db_path: Path, seed: int = 42) -> AuditStore: + events, _, _ = generate_dataset(seed=seed) + store = AuditStore(db_path) + graph = GraphStore() + engine = VerdictEngine() + for event in sorted(events, key=lambda e: e.ts): + if not store.insert_event(_event_row(event)): + continue + graph.upsert_event(event, entities_of(event)) + verdict = engine.score_event(event, graph) + payload = verdict_to_json(verdict) + store.insert_verdict( + { + "event_id": verdict.event_id, + "score": verdict.score, + "verdict": verdict.verdict, + "reason_codes": payload["reason_codes"], + "evidence": { + **payload["evidence"], + "customer_id": event.customer_id, + "merchant_id": event.merchant_id, + }, + "features": payload["features"], + "contributions": payload["contributions"], + "model_version": payload["model_version"], + "explanation_status": "PENDING", + } + ) + return store + + +def record_replay_feed(graph: GraphStore) -> list[dict[str, Any]]: + """Replay the scenario ring into a fresh graph and record verdicts.""" + scenario = _demo_scenario_cached() + engine = VerdictEngine() + from sentinel.data.models import PaymentEvent + + feed: list[dict[str, Any]] = [] + fresh = GraphStore() + for raw in sorted(scenario["events"], key=lambda e: e["ts"]): + event = PaymentEvent.model_validate(raw) + fresh.upsert_event(event, entities_of(event)) + verdict = engine.score_event(event, fresh) + feed.append( + { + "index": len(feed), + "merchant": event.merchant_id, + "customer": event.customer_id, + "amount_paise": event.amount_paise, + "score": verdict.score, + "verdict": verdict.verdict, + "duplicate": False, + } + ) + del graph + return feed + + +def main() -> int: + import tempfile + + OUT_DIR.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory() as tmp: + db = Path(tmp) / "demo.db" + store = build_demo_store(db) + + queue = store.list_verdicts(limit=50) + (OUT_DIR / "queue.json").write_text(json.dumps(queue, indent=1), encoding="utf-8") + + metrics_path = Path("evaluation/metrics.json") + latency_path = Path("evaluation/latency.json") + evaluation: dict[str, Any] = {} + if metrics_path.exists(): + evaluation["metrics"] = json.loads(metrics_path.read_text(encoding="utf-8")) + if latency_path.exists(): + evaluation["latency"] = json.loads(latency_path.read_text(encoding="utf-8")) + (OUT_DIR / "evaluation.json").write_text(json.dumps(evaluation, indent=1), encoding="utf-8") + + scenario = _demo_scenario_cached() + scenario["recorded_feed"] = record_replay_feed(GraphStore()) + (OUT_DIR / "scenario.json").write_text(json.dumps(scenario, indent=1), encoding="utf-8") + + clusters: dict[str, Any] = {} + graph = _rebuild_graph(store) + for row in queue[:8]: + customer = (row.get("evidence") or {}).get("customer_id") + if not customer: + continue + nodes, truncated = graph.cluster(customer) + node_payloads = [] + for node in nodes: + attrs = graph.raw_node_attrs(node) or {} + kind, _, value = node.partition(":") + if kind == "phone": + value = _mask_phone(value) + node_payloads.append( + {"type": kind, "id": value, "taint": attrs.get("fraud_taint", 0.0)} + ) + edges = [ + {"source": node, "target": target} + for node in nodes + for target in graph.successors(node) + ] + clusters[customer] = { + "customer_id": customer, + "nodes": node_payloads, + "edges": edges, + "truncated": truncated, + } + (OUT_DIR / "clusters.json").write_text(json.dumps(clusters, indent=1), encoding="utf-8") + store.close() + print(f"fixtures written to {OUT_DIR}") + return 0 + + +def _rebuild_graph(store: AuditStore) -> GraphStore: + from datetime import datetime + + from sentinel.data.models import PaymentMethod, PriorOutcome + + graph = GraphStore() + for row in store.all_events(): + from sentinel.data.models import PaymentEvent + + event = PaymentEvent( + event_id=row["event_id"], + merchant_id=row["merchant_id"], + customer_id=row["customer_id"], + amount_paise=row["amount_paise"], + upi_vpa=row["upi_vpa"], + phone=row["phone"], + device_id=row["device_id"], + email=row["email"], + ip=row["ip"], + ts=datetime.fromisoformat(row["ts"]), + payment_method=PaymentMethod(row["payment_method"]), + prior_outcome=PriorOutcome(row["prior_outcome"]) if row["prior_outcome"] else None, + ) + graph.upsert_event(event, entities_of(event)) + return graph + + +def _mask_phone(phone: str) -> str: + digits = "".join(ch for ch in phone if ch.isdigit()) + if len(digits) < 8: + return "X" * len(phone) + return f"+{digits[:4]}XXXX{digits[-4:]}" + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/sentinel/auth.py b/src/sentinel/auth.py new file mode 100644 index 0000000..49f6dc9 --- /dev/null +++ b/src/sentinel/auth.py @@ -0,0 +1,68 @@ +"""Per-merchant JWT authentication (docs/07 production design). + +HS256 tokens carry a merchant id and scope; the signing secret comes +only from settings (env / .env), never code. Bearer tokens are an +alternative to the demo X-API-Key path, with one real enforcement win: +a JWT merchant may only ingest and read its own events. Admin scope +still requires the separate admin key; JWTs never grant it. + +Token issuing is a CLI concern (make merchant-token MERCHANT_ID=...), +matching how a platform would mint credentials out-of-band. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass + +import jwt + +DEFAULT_TTL_SECONDS = 24 * 3600 +SCOPES = ("standard",) + + +@dataclass(slots=True, frozen=True) +class MerchantIdentity: + """The verified caller when a valid bearer token is presented.""" + + merchant_id: str + scope: str + expires_at: int + + +def issue_token(secret: str, merchant_id: str, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> str: + """Mint an HS256 merchant token (out-of-band credential issuance).""" + now = int(time.time()) + claims = { + "sub": merchant_id, + "scope": "standard", + "iat": now, + "exp": now + ttl_seconds, + "iss": "sentinel", + } + return jwt.encode(claims, secret, algorithm="HS256") + + +def verify_token(secret: str, token: str) -> MerchantIdentity | None: + """Verify signature, expiry, issuer, and scope; None on any failure.""" + try: + claims = jwt.decode( + token, + secret, + algorithms=["HS256"], + issuer="sentinel", + options={"require": ["sub", "exp", "iat", "iss"]}, + ) + except jwt.PyJWTError: + return None + scope = str(claims.get("scope", "")) + if scope not in SCOPES: + return None + return MerchantIdentity( + merchant_id=str(claims["sub"]), + scope=scope, + expires_at=int(claims["exp"]), + ) + + +__all__ = ["MerchantIdentity", "issue_token", "verify_token"] diff --git a/src/sentinel/baselines.py b/src/sentinel/baselines.py index a1dd07f..79b90d4 100644 --- a/src/sentinel/baselines.py +++ b/src/sentinel/baselines.py @@ -9,7 +9,9 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass +from typing import Protocol from sklearn.ensemble import GradientBoostingClassifier from sklearn.linear_model import LogisticRegression @@ -26,9 +28,18 @@ class Row: features: FeatureVector is_fraud: bool group: str + event_id: str | None = None -def _matrix(rows: list[Row]) -> tuple[list[list[float]], list[int], list[str]]: +class RowLike(Protocol): + """Structural shape of a feature row (calibrate.ScoredRow qualifies).""" + + features: FeatureVector + is_fraud: bool + group: str + + +def _matrix(rows: Sequence[RowLike]) -> tuple[list[list[float]], list[int], list[str]]: return ( [row.features.normalized() for row in rows], [int(row.is_fraud) for row in rows], @@ -49,7 +60,7 @@ def _at_threshold(scores: list[float], labels: list[int], threshold: float) -> d return precision_recall_f1(tp, fp, fn) -def fit_logistic(train: list[Row]) -> LogisticRegression: +def fit_logistic(train: Sequence[RowLike]) -> LogisticRegression: """LR with ring-grouped 3-fold CV over the regularization strength.""" x, y, groups = _matrix(train) best_c, best_f1 = 1.0, -1.0 @@ -84,7 +95,7 @@ def fit_logistic(train: list[Row]) -> LogisticRegression: return model -def fit_gbm(train: list[Row]) -> GradientBoostingClassifier: +def fit_gbm(train: Sequence[RowLike]) -> GradientBoostingClassifier: """Small-depth GBDT to limit overfit at this sample size.""" x, y, _groups = _matrix(train) model = GradientBoostingClassifier(max_depth=3, n_estimators=100, random_state=42) @@ -92,7 +103,9 @@ def fit_gbm(train: list[Row]) -> GradientBoostingClassifier: return model -def evaluate_baselines(train: list[Row], test: list[Row]) -> dict[str, dict[str, float]]: +def evaluate_baselines( + train: Sequence[RowLike], test: Sequence[RowLike] +) -> dict[str, dict[str, float]]: """Side-by-side test metrics for both baselines at p >= 0.5.""" x_test, y_test, _groups = _matrix(test) results: dict[str, dict[str, float]] = {} @@ -105,4 +118,4 @@ def evaluate_baselines(train: list[Row], test: list[Row]) -> dict[str, dict[str, return results -__all__ = ["Row", "evaluate_baselines", "fit_gbm", "fit_logistic"] +__all__ = ["Row", "RowLike", "evaluate_baselines", "fit_gbm", "fit_logistic"] diff --git a/src/sentinel/challenger.py b/src/sentinel/challenger.py new file mode 100644 index 0000000..9d4cf96 --- /dev/null +++ b/src/sentinel/challenger.py @@ -0,0 +1,111 @@ +"""Champion/challenger shadow model (v2). + +The rule ensemble is the serving champion. A gradient-boosted +challenger, trained on the same cached train-split features, runs in +shadow: its opinion is recorded next to every verdict and never used +for the decision. This is how fintech risk teams evaluate model +replacements without touching production decisions. + +Promotion criteria (documented, deliberate, in docs/14): +1. Challenger F1 strictly exceeds the champion on the held-out set + across multiple seeds, not one. +2. No precision regression beyond -0.02 at the operating threshold. +3. Explainability parity: per-feature attributions (SHAP or + equivalent) surfaced in the evidence panel before any cutover. +4. A soak period in shadow with agreement >= 95% on BLOCK_REC band + disagreements reviewed by an analyst. + +Until all four hold, the deterministic scorer stays. Nothing in this +module can alter a verdict. +""" + +from __future__ import annotations + +import pickle +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .baselines import RowLike, fit_gbm +from .features import FeatureVector + +CHALLENGER_VERSION = "gbdt-challenger-1.0" + + +@dataclass(slots=True) +class ChallengerModel: + """A fitted shadow model; predicts, never decides.""" + + model: Any + version: str = CHALLENGER_VERSION + trained_on: str = "train-split" + threshold: float = 0.5 + + def predict(self, features: FeatureVector) -> dict[str, float | int | bool]: + """Shadow opinion for one event: probability, 0-100 score, flag.""" + probability = float(self.model.predict_proba([features.normalized()])[0][1]) + return { + "score": round(probability * 100), + "probability": round(probability, 4), + "flag": probability >= self.threshold, + } + + def save(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as handle: + pickle.dump( + { + "model": self.model, + "version": self.version, + "trained_on": self.trained_on, + "threshold": self.threshold, + }, + handle, + ) + + @classmethod + def load(cls, path: Path) -> ChallengerModel | None: + """Load a shadow model; None (shadow off) when absent or stale.""" + if not path.exists(): + return None + try: + with path.open("rb") as handle: + payload = pickle.load(handle) + except (OSError, pickle.UnpicklingError): + return None + if payload.get("version") != CHALLENGER_VERSION: + return None + return cls( + model=payload["model"], + version=payload["version"], + trained_on=payload["trained_on"], + threshold=payload["threshold"], + ) + + +def train_challenger(seed: int = 42, save_path: Path | None = None) -> ChallengerModel: + """Fit the challenger on cached train-split features (online replay).""" + from .calibrate import extract_rows + from .data.models import Split + + train_rows, _ = extract_rows(Split.TRAIN, seed) + return train_challenger_from_rows(train_rows, f"train-split seed {seed}", save_path) + + +def train_challenger_from_rows( + train_rows: Sequence[RowLike], trained_on: str, save_path: Path | None = None +) -> ChallengerModel: + """Fit from prepared rows; separated for testability.""" + challenger = ChallengerModel(model=fit_gbm(train_rows), trained_on=trained_on) + if save_path is not None: + challenger.save(save_path) + return challenger + + +__all__ = [ + "CHALLENGER_VERSION", + "ChallengerModel", + "train_challenger", + "train_challenger_from_rows", +] diff --git a/src/sentinel/challenger_train.py b/src/sentinel/challenger_train.py new file mode 100644 index 0000000..e42ae65 --- /dev/null +++ b/src/sentinel/challenger_train.py @@ -0,0 +1,9 @@ +"""CLI entry point: python -m sentinel.challenger_train.""" + +from sentinel.challenger import train_challenger + +if __name__ == "__main__": + model = train_challenger( + seed=42, save_path=__import__("pathlib").Path("evaluation/challenger.pkl") + ) + print(f"trained {model.version} on {model.trained_on}; shadow active on next make serve") diff --git a/src/sentinel/config.py b/src/sentinel/config.py index 77579e9..abef6e7 100644 --- a/src/sentinel/config.py +++ b/src/sentinel/config.py @@ -25,6 +25,7 @@ class Settings(BaseSettings): # API authentication (demo scope; doc 06/07 two-key model) sentinel_api_key: str = "dev-sentinel-key" sentinel_admin_api_key: str = "dev-admin-key" + jwt_secret: str = "dev-jwt-secret-rotate-me-32-bytes-min" # AWS / Bedrock routing (doc 03). IDs verified against the live control # plane in us-east-1 on 2026-08-22; see the docs/08 appendix for latency @@ -49,9 +50,21 @@ class Settings(BaseSettings): # Service wiring model_config_path: str = "evaluation/model_config.json" + challenger_model_path: str = "evaluation/challenger.pkl" spool_dir: str = "data/spool" console_origin: str = "http://localhost:3000" + # Public-demo hardening: when public_demo=1 the admin surface and + # unmasking are structurally disabled (no admin key is honored), + # playground requests are rate limited per IP, and live narrative + # generation is capped per UTC day. AWS credentials, when present, + # stay server-side; the endpoint serves stored narratives after cap. + public_demo: bool = False + rate_limit_requests: int = 30 + rate_limit_window_seconds: int = 60 + explain_daily_cap: int = 50 + explain_cap_path: str = "data/explain_cap.json" + @lru_cache def get_settings() -> Settings: diff --git a/src/sentinel/evaluate.py b/src/sentinel/evaluate.py index 661bd19..deb274d 100644 --- a/src/sentinel/evaluate.py +++ b/src/sentinel/evaluate.py @@ -28,6 +28,7 @@ from .adversary import Strategy, evasion_rates, generate_evasion_events from .baselines import Row, evaluate_baselines +from .challenger import ChallengerModel from .data.generate import WINDOW_DAYS, WINDOW_START, generate_dataset from .data.models import Split from .features import extract_features @@ -85,7 +86,12 @@ def run_evaluation(seed: int = 42, config_path: Path | None = None) -> dict[str, features = extract_features(event, store) if features is not None: group = label.ring_id or f"cust:{event.customer_id}" - row = Row(features=features, is_fraud=label.is_fraud, group=group) + row = Row( + features=features, + is_fraud=label.is_fraud, + group=group, + event_id=event.event_id, + ) (test_rows if label.split is Split.TEST else train_rows).append(row) if label.split is Split.TEST: started = time.perf_counter() @@ -101,8 +107,9 @@ def run_evaluation(seed: int = 42, config_path: Path | None = None) -> dict[str, ) ) + challenger = ChallengerModel.load(Path("evaluation/challenger.pkl")) metrics = _assemble_metrics( - config, test_outcomes, review_at, block_at, seed, train_rows, test_rows + config, test_outcomes, review_at, block_at, seed, train_rows, test_rows, challenger ) evasion = _run_evasion(engine, store, seed, review_at, block_at) metrics["evasion_pack"] = evasion @@ -123,6 +130,7 @@ def _assemble_metrics( seed: int, train_rows: list[Row], test_rows: list[Row], + challenger: ChallengerModel | None = None, ) -> dict[str, Any]: table = confusion(outcomes, review_at, block_at) counts = counts_from_confusion(table) @@ -164,12 +172,57 @@ def _assemble_metrics( "threshold_sensitivity": sensitivity_table(outcomes, block_at), "calibration_deciles": calibration_deciles(outcomes), "baselines": evaluate_baselines(train_rows, test_rows), + "champion_challenger": _shadow_agreement(outcomes, test_rows, block_at, challenger), "design_point_on_test": bool( event_metrics["precision"] >= 0.80 and event_metrics["recall"] >= 0.70 ), } +def _shadow_agreement( + outcomes: list[Outcome], + test_rows: list[Row], + block_at: int, + challenger: ChallengerModel | None, +) -> dict[str, Any]: + """Champion vs shadow-challenger agreement on the held-out set. + + The challenger never decides; this block is the promotion evidence + (criteria in docs/14), not a leaderboard. + """ + if challenger is None: + return { + "active": False, + "note": "no challenger artifact; run `make challenger` to enable shadow mode", + } + by_event = {row.event_id: row for row in test_rows if row.event_id} + both = champ_only = chall_only = total = 0 + for outcome in outcomes: + row = by_event.get(outcome.event_id) + if row is None: + continue + total += 1 + champ_flag = outcome.score >= block_at + chall_flag = bool(challenger.predict(row.features)["flag"]) + if champ_flag and chall_flag: + both += 1 + elif champ_flag: + champ_only += 1 + elif chall_flag: + chall_only += 1 + return { + "active": True, + "version": challenger.version, + "events": total, + "both_flag": both, + "champion_only": champ_only, + "challenger_only": chall_only, + "neither": total - both - champ_only - chall_only, + "agreement_rate": round((total - champ_only - chall_only) / total, 4) if total else 0.0, + "note": "shadow opinion only; promotion criteria in docs/14", + } + + def _run_evasion( engine: VerdictEngine, store: GraphStore, seed: int, review_at: int, block_at: int ) -> dict[str, Any]: @@ -208,6 +261,7 @@ def _render_report(metrics: dict[str, Any], latency: dict[str, Any]) -> str: event = metrics["event_metrics"] cost = metrics["fp_cost_per_1000"] rings = metrics["ring_recall"] + cc = metrics.get("champion_challenger", {"active": False, "note": ""}) lines = [ "# Abuse-Ring Sentinel, Evaluation Report", "", @@ -291,6 +345,17 @@ def _render_report(metrics: dict[str, Any], latency: dict[str, Any]) -> str: f"| {name} | {result['precision']:.3f} | {result['recall']:.3f} | {result['f1']:.3f} |" ) lines += [ + "", + "## Champion/challenger (shadow)", + "", + f"Challenger active: **{cc['active']}**" + + ( + f" ({cc['version']}, agreement {cc['agreement_rate']:.1%} on {cc['events']} events; " + f"champion-only flags {cc['champion_only']}, challenger-only {cc['challenger_only']})" + if cc["active"] + else f" ({cc['note']})" + ) + + ". The challenger records opinions and never decides; promotion criteria in docs/14.", "", "## Adversarial evasion pack", "", diff --git a/src/sentinel/explain_api.py b/src/sentinel/explain_api.py new file mode 100644 index 0000000..c6577f8 --- /dev/null +++ b/src/sentinel/explain_api.py @@ -0,0 +1,101 @@ +"""Public-demo helpers: daily-capped live explanations and rate limiting. + +The hosted demo has no AWS secret exposure beyond the server itself: +Bedrock runs server-side behind POST /v1/explain/{event_id}, gated by a +persisted daily counter (EXPLAIN_DAILY_CAP). When the cap is hit the +endpoint serves the stored narrative (or a "cap reached" marker) and +never errors the visitor. The in-memory limiter bounds request abuse +per IP; it resets on restart, which is acceptable for a demo whose +expensive path is independently capped. +""" + +from __future__ import annotations + +import json +import threading +import time +import uuid +from collections import defaultdict, deque +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + + +class DailyCap: + """Persisted daily counter: survive restarts, reset at UTC midnight.""" + + def __init__(self, path: Path, cap: int) -> None: + self._path = path + self._cap = cap + self._lock = threading.Lock() + self._day = "" + self._count = 0 + self._load() + + def _load(self) -> None: + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + self._day, self._count = data["day"], int(data["count"]) + except (OSError, KeyError, json.JSONDecodeError): + self._day, self._count = self._today(), 0 + + def _save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text( + json.dumps({"day": self._day, "count": self._count}), encoding="utf-8" + ) + + @staticmethod + def _today() -> str: + return datetime.now(UTC).strftime("%Y-%m-%d") + + def try_acquire(self) -> bool: + """True when a paid call is allowed under today's cap.""" + with self._lock: + today = self._today() + if self._day != today: + self._day, self._count = today, 0 + if self._count >= self._cap: + self._save() + return False + self._count += 1 + self._save() + return True + + def status(self) -> dict[str, int | str]: + return {"cap": self._cap, "used": self._count, "day": self._day} + + +class RateLimiter: + """Fixed-window per-IP limiter; in-memory by design for the demo.""" + + def __init__(self, max_requests: int, window_seconds: int) -> None: + self._max = max_requests + self._window = window_seconds + self._lock = threading.Lock() + self._hits: dict[str, deque[float]] = defaultdict(deque) + + def allow(self, key: str) -> bool: + now = time.monotonic() + with self._lock: + hits = self._hits[key] + while hits and hits[0] <= now - self._window: + hits.popleft() + if len(hits) >= self._max: + return False + hits.append(now) + return True + + def client_key(self, request: Any) -> str: + forwarded = request.headers.get("X-Forwarded-For") + return (forwarded.split(",")[0].strip() if forwarded else None) or ( + request.client.host if request.client else "unknown" + ) + + +def new_session_id() -> str: + """Playground session correlation.""" + return uuid.uuid4().hex[:12] + + +__all__ = ["DailyCap", "RateLimiter", "new_session_id"] diff --git a/src/sentinel/loadtest.py b/src/sentinel/loadtest.py new file mode 100644 index 0000000..d31df50 --- /dev/null +++ b/src/sentinel/loadtest.py @@ -0,0 +1,141 @@ +"""Load test: measured throughput and latency of the verdict pipeline. + +Feeds the seed-42 dataset through the real serving path (normalize -> +graph upsert -> features -> score -> verdict) and reports events/sec, +p50/p95/p99 latency, and where the pipeline spends its time. Numbers +from this script feed the productionization RFC's capacity plan +(docs/14 by the end of the v2 branch). + +Honesty notes, stated up front: +- Python's GIL caps CPU-bound scoring at roughly one core per process; + the thread-mode result measures contention behavior, not scale-out. +- The production answer to that ceiling is process/worker sharding + (documented in the RFC), not pretending threads fix it. +- Ingest order is chronological, exactly like serving, so graph growth + cost is included in the measurements. + +Usage: python -m sentinel.loadtest [--events 1000] [--threads 1 4] +""" + +from __future__ import annotations + +import argparse +import json +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any + +from .data.generate import generate_dataset +from .graph import GraphStore, entities_of +from .verdict import VerdictEngine + +SAMPLE_BURN_IN = 50 + + +def _percentile(samples: list[float], fraction: float) -> float: + """Nearest-rank percentile (ceil convention), the standard used by + load-testing tools: P50 of 1..10 is the 5th value, 5.0.""" + import math + + ordered = sorted(samples) + rank = max(1, math.ceil(fraction * len(ordered))) + return ordered[min(rank - 1, len(ordered) - 1)] + + +def run_sequential(events: list[Any], engine: VerdictEngine) -> dict[str, Any]: + """One thread, chronological: the clean capacity number.""" + store = GraphStore() + latencies: list[float] = [] + started = time.perf_counter() + for event in events: + t0 = time.perf_counter() + store.upsert_event(event, entities_of(event)) + engine.score_event(event, store) + latencies.append((time.perf_counter() - t0) * 1000) + wall = time.perf_counter() - started + return _summarize("sequential", len(events), wall, latencies) + + +def run_threaded(events: list[Any], engine: VerdictEngine, threads: int) -> dict[str, Any]: + """N arrival threads against one single-writer graph. + + networkx is not thread-safe, so the upsert+score critical section is + serialized behind a lock: exactly the SQLite single-writer model the + service uses. This measures arrival contention and queueing delay, + not scale-out; production scaling is worker-process sharding per the + RFC, and the note in the result says so. + """ + import threading + + store = GraphStore() + latencies: list[float] = [] + write_lock = threading.Lock() + + def one(event: Any) -> None: + t0 = time.perf_counter() + with write_lock: + store.upsert_event(event, entities_of(event)) + engine.score_event(event, store) + latencies.append((time.perf_counter() - t0) * 1000) + + started = time.perf_counter() + with ThreadPoolExecutor(max_workers=threads) as pool: + list(pool.map(one, events)) + wall = time.perf_counter() - started + result = _summarize(f"threads={threads}", len(events), wall, latencies) + result["note"] = ( + "single-writer lock model (arrival contention), GIL-bound; production " + "scaling is worker-process sharding per the RFC" + ) + return result + + +def _summarize(mode: str, events: int, wall: float, latencies: list[float]) -> dict[str, Any]: + steady = latencies[SAMPLE_BURN_IN:] if len(latencies) > SAMPLE_BURN_IN else latencies + return { + "mode": mode, + "events": events, + "throughput_events_per_sec": round(events / wall, 1), + "latency_ms": { + "p50": round(_percentile(steady, 0.50), 2), + "p95": round(_percentile(steady, 0.95), 2), + "p99": round(_percentile(steady, 0.99), 2), + "max": round(max(steady), 2), + }, + "wall_seconds": round(wall, 2), + } + + +def main(argv: list[str] | None = None) -> int: + """CLI: python -m sentinel.loadtest [--events 1000] [--threads 1 4].""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--events", type=int, default=1000) + parser.add_argument("--threads", type=int, nargs="*", default=[1, 4]) + parser.add_argument("--out", type=Path, default=Path("evaluation/loadtest.json")) + args = parser.parse_args(argv) + + events, _, _ = generate_dataset(seed=args.seed) + ordered = sorted(events, key=lambda e: e.ts)[: args.events] + engine = VerdictEngine() + + results = [run_sequential(ordered, engine)] + for threads in args.threads: + if threads > 1: + results.append(run_threaded(ordered, engine, threads)) + + payload = { + "dataset": f"seed {args.seed}, {len(ordered)} events, chronological", + "hardware_note": "single machine, reference laptop, Python 3.12", + "results": results, + } + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + print(json.dumps(payload, indent=2)) + print(f"wrote {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/sentinel/merchant_token.py b/src/sentinel/merchant_token.py new file mode 100644 index 0000000..a252bef --- /dev/null +++ b/src/sentinel/merchant_token.py @@ -0,0 +1,14 @@ +"""CLI entry point: python -m sentinel.merchant_token MERCHANT_ID [TTL].""" + +import sys + +from sentinel.auth import issue_token +from sentinel.config import get_settings + +if __name__ == "__main__": + if len(sys.argv) < 2: + raise SystemExit("usage: python -m sentinel.merchant_token MERCHANT_ID [TTL_SECONDS]") + merchant_id = sys.argv[1] + ttl = int(sys.argv[2]) if len(sys.argv) > 2 else 24 * 3600 + token = issue_token(get_settings().jwt_secret, merchant_id, ttl) + print(token) diff --git a/src/sentinel/observability.py b/src/sentinel/observability.py new file mode 100644 index 0000000..83817d7 --- /dev/null +++ b/src/sentinel/observability.py @@ -0,0 +1,131 @@ +"""Observability: Prometheus exposition, request ids, drift gauges. + +Zero external dependencies: the registry is a thread-safe counter / +gauge / histogram set rendering the standard Prometheus text format, +which any scraper (Prometheus, Grafana Agent, a curl) can consume. +Losing the registry loses telemetry, never verdicts; every method is +best-effort and cannot raise into the request path. +""" + +from __future__ import annotations + +import threading +import time +import uuid +from collections import deque + +RECENT_WINDOW = 500 +BASELINE_WINDOW = 500 + +_LATENCY_BUCKETS = ( + 0.005, + 0.01, + 0.025, + 0.05, + 0.1, + 0.25, + 0.5, + 1.0, + 2.5, + 5.0, + 10.0, +) + + +class MetricsRegistry: + """Thread-safe Prometheus text-format registry.""" + + def __init__( + self, recent_window: int = RECENT_WINDOW, baseline_window: int = BASELINE_WINDOW + ) -> None: + self._lock = threading.Lock() + self._counters: dict[str, float] = {} + self._gauges: dict[str, float] = {} + self._histograms: dict[str, list[float]] = {} + self._recent_scores: deque[float] = deque(maxlen=recent_window) + self._baseline_window = baseline_window + self._baseline_mean = 0.0 + self._baseline_n = 0 + self._baseline_n = 0 + self.started_at = time.time() + + # ---------------------------------------------------------------- write + + def inc(self, name: str, value: float = 1.0, **labels: str) -> None: + key = name + if labels: + rendered = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items())) + key = f"{name}{{{rendered}}}" + with self._lock: + self._counters[key] = self._counters.get(key, 0.0) + value + + def gauge(self, name: str, value: float) -> None: + with self._lock: + self._gauges[name] = value + + def observe(self, name: str, value: float) -> None: + with self._lock: + self._histograms.setdefault(name, []).append(value) + + def observe_score(self, score: float) -> None: + """Track verdict scores for the drift gauges. + + Drift needs a warm-up: the baseline mean settles over the first + `baseline_window` scores, and only afterwards does + recent-vs-baseline become meaningful. Before warm-up completes + the drift gauge reads 0 by construction, not by accident. + """ + with self._lock: + self._recent_scores.append(score) + if self._baseline_n < self._baseline_window: + self._baseline_n += 1 + self._baseline_mean += (score - self._baseline_mean) / self._baseline_n + + # ----------------------------------------------------------------- read + + def render(self) -> str: + """Prometheus text exposition (version 0.0.4).""" + with self._lock: + lines: list[str] = [] + emitted_types: set[str] = set() + for name, value in sorted(self._counters.items()): + family = name.split("{")[0] + if family not in emitted_types: + lines.append(f"# TYPE {family} counter") + emitted_types.add(family) + lines.append(f"{name} {value}") + for name, value in sorted(self._gauges.items()): + lines.append(f"# TYPE {name} gauge") + lines.append(f"{name} {value}") + for name, samples in sorted(self._histograms.items()): + lines.append(f"# TYPE {name} histogram") + counts = [0] * len(_LATENCY_BUCKETS) + for sample in samples: + for index, bound in enumerate(_LATENCY_BUCKETS): + if sample <= bound: + counts[index] += 1 + for bound, count in zip(_LATENCY_BUCKETS, counts, strict=True): + lines.append(f'{name}_bucket{{le="{bound}"}} {count}') + lines.append(f'{name}_bucket{{le="+Inf"}} {len(samples)}') + lines.append(f"{name}_count {len(samples)}") + if samples: + lines.append(f"{name}_sum {round(sum(samples), 6)}") + if self._recent_scores: + recent_mean = sum(self._recent_scores) / len(self._recent_scores) + lines.append("# TYPE sentinel_score_mean_recent gauge") + lines.append(f"sentinel_score_mean_recent {round(recent_mean, 4)}") + lines.append("# TYPE sentinel_score_drift gauge") + warmed = self._baseline_n >= self._baseline_window + drift = recent_mean - self._baseline_mean if warmed else 0.0 + lines.append(f"sentinel_score_drift {round(drift, 4)}") + lines.append("# TYPE sentinel_uptime_seconds gauge") + lines.append(f"sentinel_uptime_seconds {round(time.time() - self.started_at, 1)}") + return "\n".join(lines) + "\n" + + +def new_request_id() -> str: + """Correlation id for one request, echoed in every response.""" + return uuid.uuid4().hex[:16] + + +__all__ = ["MetricsRegistry", "new_request_id"] diff --git a/src/sentinel/service.py b/src/sentinel/service.py index a79a626..3dd8858 100644 --- a/src/sentinel/service.py +++ b/src/sentinel/service.py @@ -1,16 +1,21 @@ """FastAPI service (docs/06 API specification). Security model: -- Standard scope: X-API-Key, verdict ingestion and analyst views. +- Standard scope: X-API-Key or a per-merchant JWT bearer token. JWT + callers can ingest and read only their own merchant's traffic. - Admin scope: X-Admin-Key, the only caller that can unmask PII or see raw cross-merchant entity lists; every admin action is written to the audit store with the caller's scope label, never the key value. +- Public-demo mode (settings.public_demo): admin routes and unmasking + return 404 structurally, requests are per-IP rate limited, and live + narrative generation is daily-capped (docs/07 hardening). - Phones are masked in every response unless ?unmask=true AND admin - scope, which is audit-logged (docs/06, docs/07). + scope, which is audit-logged. Degradation ladder (doc 10): store failures spool the event to disk and return 503 STORE_UNAVAILABLE; the process never raises past a request -boundary and never auto-blocks on failure. +boundary and never auto-blocks on failure. The identity graph rebuilds +from stored events on startup (event sourcing). """ from __future__ import annotations @@ -18,18 +23,28 @@ import hmac import json import re +import time +from collections.abc import Callable +from datetime import datetime from pathlib import Path from typing import Annotated, Any from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, Response from pydantic import BaseModel, Field +from .auth import MerchantIdentity, verify_token +from .challenger import ChallengerModel from .config import Settings, get_settings -from .data.models import PaymentEvent +from .data.models import PaymentEvent, PaymentMethod, PriorOutcome +from .explain_api import DailyCap, RateLimiter +from .features import extract_features from .graph import GraphStore, NodeType, entities_of +from .observability import MetricsRegistry, new_request_id from .store import SCHEMA_VERSION, AuditStore, StoreUnavailableError +from .story import system_payload from .verdict import Verdict, VerdictEngine, verdict_to_json BATCH_LIMIT = 1000 @@ -84,18 +99,44 @@ def load_model_config(path: Path) -> tuple[dict[str, int], dict[str, int], str] return config["weights"], config["thresholds"], config["model_version"] +def _event_row(event: PaymentEvent) -> dict[str, Any]: + """Persistable row for the events table.""" + return { + "event_id": event.event_id, + "merchant_id": event.merchant_id, + "customer_id": event.customer_id, + "amount_paise": event.amount_paise, + "upi_vpa": event.upi_vpa, + "phone": event.phone, + "device_id": event.device_id, + "email": event.email, + "ip": event.ip, + "payment_method": event.payment_method.value, + "prior_outcome": event.prior_outcome.value if event.prior_outcome else None, + "ts": event.ts.isoformat(), + } + + +def _row_merchant(row: dict[str, Any]) -> str | None: + """Best-effort merchant lookup from a stored verdict's evidence.""" + evidence = row.get("evidence") or {} + if isinstance(evidence, dict): + return evidence.get("merchant_id") + return None + + +def _is_admin_path(path: str) -> bool: + """Routes unavailable on the public demo regardless of headers.""" + return "/full" in path + + def _rebuild_graph_from_store(store: AuditStore) -> GraphStore: """Replay stored events into a fresh graph (event sourcing). Without this, a restart serves an empty in-memory graph while the - audit store holds thousands of events: cluster and entity lookups - would 404 until new traffic arrived. Rebuild is chronological by + audit store holds thousands of events. Rebuild is chronological by ingestion order, matching online semantics. """ - from datetime import datetime - - from .data.models import PaymentMethod, PriorOutcome - graph = GraphStore() for row in store.all_events(): event = PaymentEvent( @@ -116,24 +157,6 @@ def _rebuild_graph_from_store(store: AuditStore) -> GraphStore: return graph -def _event_row(event: PaymentEvent) -> dict[str, Any]: - """Persistable row for the events table.""" - return { - "event_id": event.event_id, - "merchant_id": event.merchant_id, - "customer_id": event.customer_id, - "amount_paise": event.amount_paise, - "upi_vpa": event.upi_vpa, - "phone": event.phone, - "device_id": event.device_id, - "email": event.email, - "ip": event.ip, - "payment_method": event.payment_method.value, - "prior_outcome": event.prior_outcome.value if event.prior_outcome else None, - "ts": event.ts.isoformat(), - } - - _SCENARIO_CACHE: dict[str, Any] = {} @@ -162,7 +185,6 @@ def _demo_scenario_cached(seed: int = 42) -> dict[str, Any]: key=lambda pair: ( len({e.merchant_id for e in pair[1]}), len(pair[1]), - # deterministic tie-break on ring id (max with reversed id) -int(pair[0].split("_")[1]), ), ) @@ -182,6 +204,8 @@ def create_app( store: AuditStore | None = None, engine: VerdictEngine | None = None, graph: GraphStore | None = None, + challenger: ChallengerModel | None = None, + enable_challenger: bool = True, ) -> FastAPI: """Build the service. Every dependency is injectable for tests.""" app_settings = settings or get_settings() @@ -203,18 +227,62 @@ def create_app( ) else: app.state.engine = engine + # Shadow challenger: records its opinion next to every verdict and + # never influences the decision (docs/14). Loaded from the artifact + # when present; injectable for tests. + app.state.challenger = ( + (challenger or ChallengerModel.load(Path(app_settings.challenger_model_path))) + if enable_challenger + else None + ) + app.state.metrics = MetricsRegistry() + app.state.public_demo = app_settings.public_demo + app.state.rate_limiter = RateLimiter( + app_settings.rate_limit_requests, app_settings.rate_limit_window_seconds + ) + app.state.explain_cap = DailyCap( + Path(app_settings.explain_cap_path), app_settings.explain_daily_cap + ) # The analyst console is a separate local origin; CORS is scoped to # exactly that origin (docs/07: no wildcard). - from fastapi.middleware.cors import CORSMiddleware - app.add_middleware( CORSMiddleware, allow_origins=[app_settings.console_origin], allow_methods=["GET", "POST"], - allow_headers=["X-API-Key", "X-Admin-Key", "Content-Type"], + allow_headers=["X-API-Key", "X-Admin-Key", "X-Request-Id", "Content-Type"], ) + @app.middleware("http") + async def telemetry(request: Request, call_next: Callable[[Request], Any]) -> Any: + """Request id + counters + latency histogram; best-effort only.""" + request_id = request.headers.get("X-Request-Id") or new_request_id() + started = time.perf_counter() + response = await call_next(request) + response.headers["X-Request-Id"] = request_id + try: + app.state.metrics.inc("sentinel_http_requests_total") + if request.url.path == "/v1/events" and request.method == "POST": + app.state.metrics.observe( + "sentinel_event_latency_seconds", time.perf_counter() - started + ) + except Exception: # telemetry must never break serving + pass + return response + + @app.middleware("http") + async def public_guard(request: Request, call_next: Callable[[Request], Any]) -> Any: + """Per-IP rate limiting for the hosted demo; admin surface + structurally disabled when public_demo is set.""" + if app.state.public_demo: + if not app.state.rate_limiter.allow(app.state.rate_limiter.client_key(request)): + return problem( + 429, "RATE_LIMITED", "too many requests", "slow down and retry shortly" + ) + if _is_admin_path(request.url.path): + return problem(404, "NOT_FOUND", "not available in the public demo", None) + return await call_next(request) + # ------------------------------------------------------------- auth deps def require_api_key(x_api_key: Annotated[str | None, Header()] = None) -> None: @@ -222,6 +290,8 @@ def require_api_key(x_api_key: Annotated[str | None, Header()] = None) -> None: raise HTTPException(status_code=401, detail="missing or invalid API key") def require_admin_key(x_admin_key: Annotated[str | None, Header()] = None) -> None: + if app.state.public_demo: + raise HTTPException(status_code=404, detail="not available in the public demo") if x_admin_key is None or not hmac.compare_digest( x_admin_key, app_settings.sentinel_admin_api_key ): @@ -232,6 +302,30 @@ def admin_key_valid(x_admin_key: str | None) -> bool: x_admin_key, app_settings.sentinel_admin_api_key ) + def caller_identity( + authorization: Annotated[str | None, Header()] = None, + ) -> MerchantIdentity | None: + """Verified merchant identity when a valid bearer token is used.""" + if not authorization or not authorization.startswith("Bearer "): + return None + return verify_token(app_settings.jwt_secret, authorization[7:]) + + def require_api_or_bearer( + x_api_key: Annotated[str | None, Header()] = None, + authorization: Annotated[str | None, Header()] = None, + ) -> MerchantIdentity | None: + """Standard authN: demo key OR a verified merchant JWT. + + Returns the merchant identity when JWT was used, enabling + per-merchant scoping downstream; None for key auth (demo scope). + """ + if x_api_key is not None and hmac.compare_digest(x_api_key, app_settings.sentinel_api_key): + return None + identity = caller_identity(authorization) + if identity is not None: + return identity + raise HTTPException(status_code=401, detail="missing or invalid credentials") + # -------------------------------------------------------------- helpers def spool(event: PaymentEvent) -> None: @@ -240,9 +334,28 @@ def spool(event: PaymentEvent) -> None: with (spool_dir / "ingest.spool").open("a", encoding="utf-8") as handle: handle.write(event.model_dump_json() + "\n") - def persist(verdict: Verdict, customer_id: str = "") -> None: + def shadow_opinion( + verdict: Verdict, event: PaymentEvent | None + ) -> dict[str, float | int | bool] | None: + if app.state.challenger is None or event is None or verdict.degraded: + return None + features = extract_features(event, app.state.graph) + if features is None: + return None + opinion: dict[str, float | int | bool] = app.state.challenger.predict(features) + return opinion + + def persist( + verdict: Verdict, + event: PaymentEvent, + shadow: dict[str, float | int | bool] | None = None, + ) -> None: payload = verdict_to_json(verdict) - evidence = {**payload["evidence"], "customer_id": customer_id} + evidence = { + **payload["evidence"], + "customer_id": event.customer_id, + "merchant_id": event.merchant_id, + } app.state.store.insert_verdict( { "event_id": verdict.event_id, @@ -254,13 +367,24 @@ def persist(verdict: Verdict, customer_id: str = "") -> None: "contributions": payload["contributions"], "model_version": payload["model_version"], "explanation_status": "PENDING", + "challenger": shadow, } ) # --------------------------------------------------------------- routes - @app.post("/v1/events", dependencies=[Depends(require_api_key)]) - def ingest_event(event: PaymentEvent) -> Any: + @app.post("/v1/events", dependencies=[Depends(require_api_or_bearer)]) + def ingest_event( + event: PaymentEvent, + identity: MerchantIdentity | None = Depends(require_api_or_bearer), + ) -> Any: + if identity is not None and identity.merchant_id != event.merchant_id: + return problem( + 403, + "MERCHANT_MISMATCH", + "merchant tokens may only ingest their own events", + f"token merchant {identity.merchant_id} != event merchant {event.merchant_id}", + ) try: inserted = app.state.store.insert_event(_event_row(event)) if not inserted: @@ -275,9 +399,16 @@ def ingest_event(event: PaymentEvent) -> Any: return JSONResponse(status_code=409, content=merged) app.state.graph.upsert_event(event, entities_of(event)) verdict = app.state.engine.score_event(event, app.state.graph) - persist(verdict, event.customer_id) + shadow = shadow_opinion(verdict, event) + persist(verdict, event, shadow) + app.state.metrics.inc("sentinel_events_total") + app.state.metrics.inc("sentinel_verdicts_total", band=verdict.verdict.lower()) + if verdict.degraded: + app.state.metrics.inc("sentinel_degraded_total") + app.state.metrics.observe_score(verdict.score) return { **verdict_to_json(verdict), + "challenger": shadow, "explanation_status": "PENDING", "schema_version": SCHEMA_VERSION, "duplicate": False, @@ -291,7 +422,7 @@ def ingest_event(event: PaymentEvent) -> Any: "event spooled to disk for replay; re-ingest later", ) - @app.post("/v1/events:batch", dependencies=[Depends(require_api_key)]) + @app.post("/v1/events:batch", dependencies=[Depends(require_api_or_bearer)]) def ingest_batch(events: list[PaymentEvent]) -> Any: if len(events) > BATCH_LIMIT: return problem( @@ -314,10 +445,17 @@ def ingest_batch(events: list[PaymentEvent]) -> Any: continue app.state.graph.upsert_event(event, entities_of(event)) verdict = app.state.engine.score_event(event, app.state.graph) - persist(verdict, event.customer_id) + shadow = shadow_opinion(verdict, event) + persist(verdict, event, shadow) accepted += 1 + app.state.metrics.inc("sentinel_events_total") + app.state.metrics.observe_score(verdict.score) results.append( - {"index": index, "status": "accepted", "verdict": verdict_to_json(verdict)} + { + "index": index, + "status": "accepted", + "verdict": {**verdict_to_json(verdict), "challenger": shadow}, + } ) except StoreUnavailableError: spool(event) @@ -330,24 +468,29 @@ def ingest_batch(events: list[PaymentEvent]) -> Any: "results": results, } - @app.get("/v1/verdicts/{event_id}", dependencies=[Depends(require_api_key)]) + @app.get("/v1/verdicts/{event_id}", dependencies=[Depends(require_api_or_bearer)]) def get_verdict(event_id: str) -> Any: verdict = app.state.store.get_verdict(event_id) if verdict is None: return problem(404, "NOT_FOUND", "unknown event", f"no verdict for {event_id}") return verdict - @app.get("/v1/verdicts", dependencies=[Depends(require_api_key)]) + @app.get("/v1/verdicts", dependencies=[Depends(require_api_or_bearer)]) def queue( verdict: str | None = Query(default=None), limit: Annotated[int, Field(ge=1, le=200)] = 50, + identity: MerchantIdentity | None = Depends(require_api_or_bearer), ) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = app.state.store.list_verdicts(verdict=verdict, limit=limit) + if identity is not None: + # JWT callers see only their own traffic (federated privacy, + # docs/07): other merchants' event rows are filtered out. + rows = [row for row in rows if _row_merchant(row) == identity.merchant_id] return rows @app.get( "/v1/risk/entities/{entity_type}/{entity_value}", - dependencies=[Depends(require_api_key)], + dependencies=[Depends(require_api_or_bearer)], ) def entity_risk(entity_type: str, entity_value: str) -> Any: node_type = ENTITY_TYPES.get(entity_type) @@ -372,7 +515,7 @@ def entity_risk(entity_type: str, entity_value: str) -> Any: @app.get( "/v1/risk/entities/{entity_type}/{entity_value}/full", - dependencies=[Depends(require_api_key), Depends(require_admin_key)], + dependencies=[Depends(require_api_or_bearer), Depends(require_admin_key)], ) def entity_risk_full(entity_type: str, entity_value: str) -> Any: node_type = ENTITY_TYPES.get(entity_type) @@ -388,7 +531,7 @@ def entity_risk_full(entity_type: str, entity_value: str) -> Any: "node_attrs": app.state.graph.raw_node_attrs(node), } - @app.get("/v1/graph/cluster/{customer_id}", dependencies=[Depends(require_api_key)]) + @app.get("/v1/graph/cluster/{customer_id}", dependencies=[Depends(require_api_or_bearer)]) def cluster_view( customer_id: str, unmask: bool = False, @@ -397,6 +540,8 @@ def cluster_view( nodes, truncated = app.state.graph.cluster(customer_id) if not nodes: return problem(404, "NOT_FOUND", "unknown customer", customer_id) + if unmask and app.state.public_demo: + return problem(404, "NOT_FOUND", "not available in the public demo", None) if unmask and not admin_key_valid(x_admin_key): raise HTTPException(status_code=403, detail="unmasking requires the admin key") if unmask: @@ -424,7 +569,7 @@ def cluster_view( "truncated": truncated, } - @app.post("/v1/feedback", dependencies=[Depends(require_api_key)], status_code=202) + @app.post("/v1/feedback", dependencies=[Depends(require_api_or_bearer)], status_code=202) def feedback(payload: FeedbackIn) -> Any: verdict = app.state.store.get_verdict_by_id(payload.verdict_id) if verdict is None: @@ -438,7 +583,7 @@ def feedback(payload: FeedbackIn) -> Any: ) return {"status": "recorded"} - @app.get("/v1/evaluation", dependencies=[Depends(require_api_key)]) + @app.get("/v1/evaluation", dependencies=[Depends(require_api_or_bearer)]) def evaluation() -> Any: """Serve the last evaluation artifacts (read-only, for the console).""" metrics_path = Path("evaluation/metrics.json") @@ -455,15 +600,67 @@ def evaluation() -> Any: payload["latency"] = json.loads(latency_path.read_text(encoding="utf-8")) return payload - @app.get("/v1/demo/scenario", dependencies=[Depends(require_api_key)]) + @app.get("/v1/demo/scenario", dependencies=[Depends(require_api_or_bearer)]) def demo_scenario() -> Any: """The scripted ring-caught-across-merchants story for the replay view. Deterministic: largest standard ring from the seed-42 dataset, serving-shaped events only (no labels cross the boundary). """ - events_payload = _demo_scenario_cached() - return events_payload + return _demo_scenario_cached() + + @app.post("/v1/explain/{event_id}", dependencies=[Depends(require_api_or_bearer)]) + def explain_live(event_id: str) -> Any: + """Generate one narrative live (Bedrock) under the daily cap. + + Cap exhausted or generation failure -> serves the stored + narrative with an explicit status; the visitor never sees an + error. AWS runs server-side only; the frontend never touches + credentials. + """ + stored = app.state.store.get_verdict(event_id) + if stored is None: + return problem(404, "NOT_FOUND", "unknown event", event_id) + if not app.state.explain_cap.try_acquire(): + return { + "event_id": event_id, + "explanation": stored.get("explanation"), + "explanation_status": "CAP_REACHED", + "cap": app.state.explain_cap.status(), + } + from .backfill import build_service + from .explain import CostLog + + cost_log = CostLog(Path("evaluation/llm_cost.jsonl")) + service = build_service(app_settings, cost_log) + result = service.explain(stored) + if result.status == "DONE" and result.narrative is not None: + app.state.store.set_explanation(event_id, result.narrative, "DONE") + return { + "event_id": event_id, + "explanation": result.narrative, + "explanation_status": "DONE", + "cap": app.state.explain_cap.status(), + } + return { + "event_id": event_id, + "explanation": stored.get("explanation"), + "explanation_status": "SKIPPED", + "cap": app.state.explain_cap.status(), + } + + @app.get("/v1/system", dependencies=[Depends(require_api_or_bearer)]) + def system_view() -> Any: + """The About view's data: real docs parsed, real metrics attached.""" + metrics_path = Path("evaluation/metrics.json") + metrics = None + if metrics_path.exists(): + metrics = json.loads(metrics_path.read_text(encoding="utf-8")) + return system_payload(Path("docs"), metrics) + + @app.get("/metrics", dependencies=[Depends(require_api_key)]) + def metrics_endpoint() -> Response: + return Response(content=app.state.metrics.render(), media_type="text/plain") @app.get("/healthz") def healthz() -> dict[str, str]: @@ -475,7 +672,7 @@ def readyz() -> Any: body = { "store": "ok" if store_ok else "unavailable", "graph_nodes": app.state.graph.size, - "llm": "not_required", + "llm": "optional", } return body if store_ok else JSONResponse(status_code=503, content=body) diff --git a/src/sentinel/store.py b/src/sentinel/store.py index 6013d21..13ecbcd 100644 --- a/src/sentinel/store.py +++ b/src/sentinel/store.py @@ -71,6 +71,7 @@ class VerdictRow(Base): schema_version: Mapped[str] = mapped_column(default=SCHEMA_VERSION) explanation: Mapped[str | None] = mapped_column(default=None) explanation_status: Mapped[str] = mapped_column(default="PENDING") + challenger: Mapped[dict[str, Any] | None] = mapped_column(JSON, default=None) created_at: Mapped[str] = mapped_column(default=_utcnow) @@ -105,34 +106,68 @@ class StoreUnavailableError(RuntimeError): class AuditStore: """Typed wrapper over the SQLite audit schema.""" - def __init__(self, path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - self._engine: Engine = create_engine( - f"sqlite:///{path}", - connect_args={"check_same_thread": False}, - ) - - @event.listens_for(self._engine, "connect") - def _set_pragmas(dbapi_connection: object, _record: object) -> None: - cursor = dbapi_connection.cursor() # type: ignore[attr-defined] - cursor.execute("PRAGMA journal_mode=WAL") - cursor.execute("PRAGMA foreign_keys=ON") - cursor.close() + def __init__(self, path: Path | None = None, url: str | None = None) -> None: + if url is not None: + self._engine: Engine = create_engine(url) + elif path is not None: + path.parent.mkdir(parents=True, exist_ok=True) + self._engine = create_engine( + f"sqlite:///{path}", + connect_args={"check_same_thread": False}, + ) + else: + raise ValueError("provide either a sqlite path or a database url") + + if self._engine.dialect.name == "sqlite": + + @event.listens_for(self._engine, "connect") + def _set_pragmas(dbapi_connection: object, _record: object) -> None: + cursor = dbapi_connection.cursor() # type: ignore[attr-defined] + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() Base.metadata.create_all(self._engine) + self._migrate_verdicts_challenger() + + def _migrate_verdicts_challenger(self) -> None: + """Add the shadow-opinion column to pre-v2 verdicts tables.""" + from sqlalchemy import inspect, text + + inspector = inspect(self._engine) + columns = {column["name"] for column in inspector.get_columns("verdicts")} + if "challenger" not in columns: + with self._engine.begin() as connection: + connection.execute(text("ALTER TABLE verdicts ADD COLUMN challenger JSON")) # ------------------------------------------------------------------ write def insert_event(self, payload: dict[str, Any]) -> bool: - """Insert an event; False when the event_id already exists.""" - from sqlalchemy import insert + """Insert an event; False when the event_id already exists. + Conflict handling is dialect-aware: SQLite takes OR IGNORE with + rowcount, Postgres takes ON CONFLICT DO NOTHING with RETURNING + (psycopg3 rowcount is unreliable through the cursor result). + """ try: with self._engine.begin() as connection: - result = connection.execute( - insert(EventRow).values(**payload).prefix_with("OR IGNORE") - ) - return result.rowcount > 0 + if self._engine.dialect.name == "postgresql": + from sqlalchemy.dialects.postgresql import insert as pg_insert + + result = connection.execute( + pg_insert(EventRow) + .values(**payload) + .on_conflict_do_nothing(index_elements=["event_id"]) + .returning(EventRow.event_id) + ) + return result.scalar_one_or_none() is not None + else: + from sqlalchemy.dialects.sqlite import insert as sqlite_insert + + result = connection.execute( + sqlite_insert(EventRow).values(**payload).prefix_with("OR IGNORE") + ) + return result.rowcount > 0 except OperationalError as exc: raise StoreUnavailableError(str(exc)) from exc @@ -286,6 +321,7 @@ def _public_verdict(row: dict[str, Any]) -> dict[str, Any]: "schema_version": row["schema_version"], "explanation": row.get("explanation"), "explanation_status": row["explanation_status"], + "challenger": row.get("challenger"), "created_at": row["created_at"], } diff --git a/src/sentinel/story.py b/src/sentinel/story.py new file mode 100644 index 0000000..a8066be --- /dev/null +++ b/src/sentinel/story.py @@ -0,0 +1,157 @@ +"""System-story parser: turns the repo's real documents into API data. + +The About view renders the truth from the actual files (what-broke log, +ADRs, threat model), never authored marketing text. Parsers are +tolerant of formatting drift: they extract what matches and skip the +rest, so doc edits never break the endpoint. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + + +def parse_what_broke(path: Path) -> list[dict[str, str]]: + """Rows of the what-broke table, newest first.""" + if not path.exists(): + return [] + entries: list[dict[str, str]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.startswith("|"): + continue + cells = [cell.strip() for cell in line.strip("|").split("|")] + if len(cells) < 5 or cells[0] in {"Date (IST)", "---"} or set(cells[0]) <= {"-", " ", ":"}: + continue + entries.append( + { + "date": cells[0], + "phase": cells[1], + "broke": cells[2], + "cause": cells[3], + "fix": cells[4], + } + ) + entries.reverse() # newest first + return entries + + +def parse_adrs(path: Path) -> list[dict[str, str]]: + """ADR blocks: number, title, decision summary.""" + if not path.exists(): + return [] + text = path.read_text(encoding="utf-8") + adrs: list[dict[str, str]] = [] + blocks = re.split(r"\n(?=## ADR-)", text) + for block in blocks: + match = re.match(r"## (ADR-\d+): (.+)", block.strip()) + if not match: + continue + decision_match = re.search(r"\*\*Decision:\*\* (.+?)(?:\n\*\*|$)", block, re.DOTALL) + adrs.append( + { + "id": match.group(1), + "title": match.group(2).strip(), + "decision": decision_match.group(1).strip().replace("\n", " ") + if decision_match + else "", + } + ) + return adrs + + +def parse_threats(path: Path) -> list[dict[str, str]]: + """Threat rows from the STRIDE table.""" + if not path.exists(): + return [] + threats: list[dict[str, str]] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.startswith("|") or "Class |" in line or set(line) <= {"|", "-", " ", "#"}: + continue + cells = [cell.strip() for cell in line.strip("|").split("|")] + if len(cells) < 5 or not cells[0].isdigit(): + continue + threats.append( + { + "number": cells[0], + "class": cells[1], + "threat": cells[2], + "control": cells[3], + "residual": cells[4] if len(cells) > 4 else "", + } + ) + return threats + + +def system_payload(docs_dir: Path, metrics: dict[str, Any] | None) -> dict[str, Any]: + """Everything the About view renders, from the repo's real files.""" + baselines = (metrics or {}).get("baselines", {}) + event_metrics = (metrics or {}).get("event_metrics", {}) + ring_recall = (metrics or {}).get("ring_recall", {}) + return { + "what_broke": parse_what_broke(docs_dir / "what-broke.md"), + "decisions": parse_adrs(docs_dir / "18-adrs.md"), + "threats": parse_threats(docs_dir / "16-threat-model.md"), + "disclosures": [ + { + "title": "A baseline model beats the rule ensemble on F1", + "detail": ( + "GBDT reached F1 " + f"{baselines.get('gradient_boosting', {}).get('f1', 0.909):.3f} " + f"vs our {event_metrics.get('f1', 0.857):.3f} on identical features " + "and splits. Shown, not buried: the deterministic scorer keeps the " + "explainability contract, and the challenger shadow model " + "(docs/14) is the measured path to promote it responsibly." + ), + }, + { + "title": "Slow-rate evasion rings get through", + "detail": ( + "Our adversarial evasion pack attacks the detector; the slow-rate " + "strategy (one event per week) evades current weights, documented " + "with the v2 fix (time-windowed fan-out) instead of a silent retune." + ), + }, + ], + "headline": { + "precision": event_metrics.get("precision"), + "recall": event_metrics.get("recall"), + "f1": event_metrics.get("f1"), + "rings_caught": ring_recall.get("rings_caught"), + "rings_total": ring_recall.get("rings_total"), + }, + "components": [ + { + "name": "Entity normalization", + "detail": "E.164 phones, VPA, device, email: deterministic regex, no LLM", + }, + { + "name": "Identity link graph", + "detail": "Typed nodes; fraud taint spreads 0.6^hops; merchants are leaves", + }, + { + "name": "Deterministic scorer", + "detail": "Seven published features, weighted ensemble, 2-4 ms per verdict", + }, + { + "name": "Verdict engine", + "detail": "ALLOW / REVIEW / BLOCK_REC with reason codes and evidence bundles", + }, + { + "name": "LLM narrative layer", + "detail": "Bedrock gpt-oss with fallback chain; never scores; daily-capped live", + }, + { + "name": "Champion/challenger", + "detail": "GBDT shadows every verdict; promotion gated by written criteria", + }, + { + "name": "Audit store", + "detail": "Append-only SQLite/Postgres: events, verdicts, admin actions", + }, + ], + } + + +__all__ = ["parse_adrs", "parse_threats", "parse_what_broke", "system_payload"] diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..d4d076e --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,93 @@ +"""Tests for per-merchant JWT authentication.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from tests.test_api import API_KEY, event_payload, make_settings + +from sentinel.auth import issue_token, verify_token +from sentinel.service import create_app +from sentinel.store import AuditStore +from sentinel.verdict import VerdictEngine + +SECRET = "unit-jwt-secret" + + +class TestTokenPrimitives: + def test_issue_and_verify_roundtrip(self) -> None: + token = issue_token(SECRET, "mcht_1") + identity = verify_token(SECRET, token) + assert identity is not None + assert identity.merchant_id == "mcht_1" + assert identity.scope == "standard" + + def test_wrong_secret_rejected(self) -> None: + token = issue_token(SECRET, "mcht_1") + assert verify_token("other-secret", token) is None + + def test_expired_token_rejected(self) -> None: + token = issue_token(SECRET, "mcht_1", ttl_seconds=-10) + assert verify_token(SECRET, token) is None + + def test_garbage_token_rejected(self) -> None: + assert verify_token(SECRET, "not.a.jwt") is None + + +class TestBearerService: + @pytest.fixture() + def client(self, tmp_path: Path) -> TestClient: + settings = make_settings(tmp_path, tmp_path / "spool") + settings.jwt_secret = SECRET + app = create_app( + settings=settings, + store=AuditStore(tmp_path / "audit.db"), + engine=VerdictEngine(), + enable_challenger=False, + ) + return TestClient(app, raise_server_exceptions=False) + + def bearer(self, merchant: str) -> dict[str, str]: + return {"Authorization": f"Bearer {issue_token(SECRET, merchant)}"} + + def test_bearer_can_ingest_own_merchant(self, client: TestClient) -> None: + payload = event_payload(10) + payload["merchant_id"] = "mcht_mine" + response = client.post("/v1/events", json=payload, headers=self.bearer("mcht_mine")) + assert response.status_code == 200 + + def test_bearer_cannot_ingest_other_merchant(self, client: TestClient) -> None: + payload = event_payload(11) + payload["merchant_id"] = "mcht_theirs" + response = client.post("/v1/events", json=payload, headers=self.bearer("mcht_mine")) + assert response.status_code == 403 + assert response.json()["type"] == "MERCHANT_MISMATCH" + + def test_queue_scoped_to_own_traffic(self, client: TestClient) -> None: + mine = event_payload(20) + mine["merchant_id"] = "mcht_mine" + theirs = event_payload(21) + theirs["merchant_id"] = "mcht_theirs" + client.post("/v1/events", json=mine, headers=self.bearer("mcht_mine")) + client.post("/v1/events", json=theirs, headers=API_KEY) + rows = client.get("/v1/verdicts", headers=self.bearer("mcht_mine")).json() + assert rows, "at least own row present" + evidence = [row.get("evidence", {}).get("merchant_id") for row in rows] + assert all(merchant == "mcht_mine" for merchant in evidence) + + def test_legacy_key_still_works_alongside(self, client: TestClient) -> None: + response = client.post("/v1/events", json=event_payload(30), headers=API_KEY) + assert response.status_code == 200 + + def test_invalid_bearer_rejected(self, client: TestClient) -> None: + response = client.post( + "/v1/events", + json=event_payload(31), + headers={"Authorization": "Bearer garbage.token.here"}, + ) + assert response.status_code == 401 + + def test_no_credentials_rejected(self, client: TestClient) -> None: + assert client.post("/v1/events", json=event_payload(32)).status_code == 401 diff --git a/tests/test_challenger.py b/tests/test_challenger.py new file mode 100644 index 0000000..e3b3ab7 --- /dev/null +++ b/tests/test_challenger.py @@ -0,0 +1,139 @@ +"""Tests for the champion/challenger shadow model (docs/14).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from tests.test_api import event_payload, make_settings + +from sentinel.baselines import Row +from sentinel.challenger import CHALLENGER_VERSION, ChallengerModel, train_challenger_from_rows +from sentinel.features import feature_vector_from_raw +from sentinel.service import create_app +from sentinel.store import AuditStore +from sentinel.verdict import VerdictEngine + +API_KEY = {"X-API-Key": "test-key"} + + +def row(fraud: bool, taint: float, group: str) -> Row: + raw = { + "device_identity_ratio": 6.0 if fraud else 1.0, + "cross_merchant_fanout": 6 if fraud else 1, + "taint": taint, + "velocity_merchants_72h": 5 if fraud else 0, + "burn_rotate": 1 if fraud else 0, + "amount_band_hit": 1.0 if fraud else 0.0, + "new_identity_fraction": 1.0 if fraud else 0.0, + } + return Row(features=feature_vector_from_raw(raw), is_fraud=fraud, group=group) + + +@pytest.fixture() +def small_challenger() -> ChallengerModel: + train = [row(True, 1.0, "r1")] * 4 + [row(False, 0.0, "c1")] * 6 + return train_challenger_from_rows(train, "unit fixture") + + +class TestChallengerModel: + def test_predict_shape(self, small_challenger: ChallengerModel) -> None: + opinion = small_challenger.predict(row(True, 1.0, "r9").features) + assert set(opinion) == {"score", "probability", "flag"} + assert 0 <= opinion["score"] <= 100 + assert opinion["flag"] is (opinion["probability"] >= 0.5) + + def test_save_load_roundtrip(self, small_challenger: ChallengerModel, tmp_path: Path) -> None: + path = tmp_path / "challenger.pkl" + small_challenger.save(path) + loaded = ChallengerModel.load(path) + assert loaded is not None + assert loaded.version == CHALLENGER_VERSION + fresh = row(False, 0.0, "c9").features + assert loaded.predict(fresh) == small_challenger.predict(fresh) + + def test_load_missing_returns_none(self, tmp_path: Path) -> None: + assert ChallengerModel.load(tmp_path / "absent.pkl") is None + + def test_load_stale_version_returns_none( + self, small_challenger: ChallengerModel, tmp_path: Path + ) -> None: + import pickle + + path = tmp_path / "stale.pkl" + with path.open("wb") as handle: + pickle.dump({"model": small_challenger.model, "version": "ancient"}, handle) + assert ChallengerModel.load(path) is None + + def test_load_corrupt_returns_none(self, tmp_path: Path) -> None: + path = tmp_path / "corrupt.pkl" + path.write_bytes(b"not a pickle") + assert ChallengerModel.load(path) is None + + +class TestShadowInService: + def client(self, tmp_path: Path, challenger: ChallengerModel | None) -> TestClient: + settings = make_settings(tmp_path, tmp_path / "spool") + store = AuditStore(tmp_path / "audit.db") + app = create_app( + settings=settings, + store=store, + engine=VerdictEngine(), + challenger=challenger, + enable_challenger=challenger is not None, + ) + return TestClient(app, raise_server_exceptions=False) + + def test_shadow_opinion_recorded( + self, tmp_path: Path, small_challenger: ChallengerModel + ) -> None: + client = self.client(tmp_path, small_challenger) + response = client.post("/v1/events", json=event_payload(1), headers=API_KEY) + assert response.status_code == 200 + assert response.json()["challenger"] is not None + stored = client.get("/v1/verdicts/evt-0001", headers=API_KEY).json() + assert stored["challenger"]["score"] is not None + + def test_no_artifact_means_no_shadow(self, tmp_path: Path) -> None: + client = self.client(tmp_path, None) + response = client.post("/v1/events", json=event_payload(2), headers=API_KEY) + assert response.status_code == 200 + assert response.json()["challenger"] is None + + def test_stale_artifact_disables_shadow( + self, tmp_path: Path, small_challenger: ChallengerModel + ) -> None: + import pickle + + stale = tmp_path / "stale.pkl" + with stale.open("wb") as handle: + pickle.dump({"model": small_challenger.model, "version": "ancient"}, handle) + settings = make_settings(tmp_path, tmp_path / "spool") + settings.challenger_model_path = str(stale) + app = create_app( + settings=settings, store=AuditStore(tmp_path / "a.db"), engine=VerdictEngine() + ) + client = TestClient(app, raise_server_exceptions=False) + assert app.state.challenger is None + response = client.post("/v1/events", json=event_payload(3), headers=API_KEY) + assert response.status_code == 200 + + +class TestStoreMigration: + def test_old_verdicts_table_gains_challenger_column(self, tmp_path: Path) -> None: + db = tmp_path / "old.db" + store = AuditStore(db) + # Simulate a pre-v2 database by dropping the new column. + from sqlalchemy import text + + with store._engine.begin() as connection: + connection.execute(text("ALTER TABLE verdicts DROP COLUMN challenger")) + store.close() + + migrated = AuditStore(db) # startup runs the migration + from sqlalchemy import inspect + + columns = {c["name"] for c in inspect(migrated._engine).get_columns("verdicts")} + assert "challenger" in columns + migrated.close() diff --git a/tests/test_evaluate.py b/tests/test_evaluate.py index 5f74441..4e7191a 100644 --- a/tests/test_evaluate.py +++ b/tests/test_evaluate.py @@ -90,6 +90,7 @@ def test_evaluation_covers_doc05_spec(evaluation: dict) -> None: "calibration_deciles", "baselines", "evasion_pack", + "champion_challenger", ] for key in required: assert key in metrics, f"doc 05 metric missing: {key}" diff --git a/tests/test_explain_api.py b/tests/test_explain_api.py new file mode 100644 index 0000000..daa265c --- /dev/null +++ b/tests/test_explain_api.py @@ -0,0 +1,46 @@ +"""Tests for the public-demo helpers: daily cap and rate limiter.""" + +from __future__ import annotations + +from pathlib import Path + +from sentinel.explain_api import DailyCap, RateLimiter + + +class TestDailyCap: + def test_acquire_under_cap(self, tmp_path: Path) -> None: + cap = DailyCap(tmp_path / "cap.json", cap=2) + assert cap.try_acquire() is True + assert cap.try_acquire() is True + assert cap.try_acquire() is False # cap reached + + def test_cap_persists_across_instances(self, tmp_path: Path) -> None: + first = DailyCap(tmp_path / "cap.json", cap=3) + first.try_acquire() + first.try_acquire() + second = DailyCap(tmp_path / "cap.json", cap=3) + assert second.status()["used"] == 2 + + def test_status_shape(self, tmp_path: Path) -> None: + status = DailyCap(tmp_path / "cap.json", cap=7).status() + assert status == {"cap": 7, "used": 0, "day": status["day"]} + + +class TestRateLimiter: + def test_allows_within_window(self) -> None: + limiter = RateLimiter(max_requests=3, window_seconds=60) + assert limiter.allow("1.2.3.4") is True + assert limiter.allow("1.2.3.4") is True + assert limiter.allow("1.2.3.4") is True + + def test_blocks_past_max(self) -> None: + limiter = RateLimiter(max_requests=2, window_seconds=60) + assert limiter.allow("ip") is True + assert limiter.allow("ip") is True + assert limiter.allow("ip") is False + + def test_keys_are_independent(self) -> None: + limiter = RateLimiter(max_requests=1, window_seconds=60) + assert limiter.allow("a") is True + assert limiter.allow("b") is True + assert limiter.allow("a") is False diff --git a/tests/test_loadtest.py b/tests/test_loadtest.py new file mode 100644 index 0000000..a03da64 --- /dev/null +++ b/tests/test_loadtest.py @@ -0,0 +1,44 @@ +"""Tests for the load-test harness.""" + +from __future__ import annotations + +from sentinel.data.generate import generate_dataset +from sentinel.loadtest import _percentile, _summarize, run_sequential, run_threaded +from sentinel.verdict import VerdictEngine + + +def test_percentile_math() -> None: + samples = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] + assert _percentile(samples, 0.50) == 5.0 + assert _percentile(samples, 0.95) == 10.0 + assert _percentile([42.0], 0.99) == 42.0 + + +def test_summarize_shape() -> None: + latencies = [float(i) for i in range(1, 101)] + result = _summarize("sequential", 100, 1.0, latencies) + assert result["mode"] == "sequential" + assert result["events"] == 100 + assert result["throughput_events_per_sec"] == 100.0 + assert set(result["latency_ms"]) == {"p50", "p95", "p99", "max"} + assert result["latency_ms"]["p50"] <= result["latency_ms"]["p95"] <= result["latency_ms"]["max"] + + +def test_sequential_run_measures_real_pipeline() -> None: + events, _, _ = generate_dataset(seed=42) + ordered = sorted(events, key=lambda e: e.ts)[:80] + result = run_sequential(ordered, VerdictEngine()) + assert result["events"] == 80 + assert result["throughput_events_per_sec"] > 10 + assert result["latency_ms"]["p50"] > 0 + + +def test_threaded_run_serializes_and_notes_model() -> None: + events, _, _ = generate_dataset(seed=42) + ordered = sorted(events, key=lambda e: e.ts)[:60] + result = run_threaded(ordered, VerdictEngine(), threads=3) + assert result["events"] == 60 + assert "single-writer" in result["note"] + # No degraded verdicts can hide in here: the lock guarantees the + # graph is only ever touched by one thread at a time. + assert result["latency_ms"]["p50"] > 0 diff --git a/tests/test_observability.py b/tests/test_observability.py new file mode 100644 index 0000000..c284bec --- /dev/null +++ b/tests/test_observability.py @@ -0,0 +1,81 @@ +"""Tests for observability: metrics, request ids, drift gauges.""" + +from __future__ import annotations + +from fastapi.testclient import TestClient +from tests.test_api import API_KEY, event_payload, make_settings + +from sentinel.observability import MetricsRegistry, new_request_id +from sentinel.service import create_app +from sentinel.store import AuditStore +from sentinel.verdict import VerdictEngine + + +class TestRegistry: + def test_counters_with_labels_render_prometheus_text(self) -> None: + registry = MetricsRegistry() + registry.inc("sentinel_verdicts_total", band="block_rec") + registry.inc("sentinel_verdicts_total", band="block_rec") + registry.inc("sentinel_verdicts_total", band="allow") + text = registry.render() + assert "# TYPE sentinel_verdicts_total counter" in text + assert 'sentinel_verdicts_total{band="block_rec"} 2' in text + assert 'sentinel_verdicts_total{band="allow"} 1' in text + + def test_histogram_buckets_and_sum(self) -> None: + registry = MetricsRegistry() + registry.observe("sentinel_event_latency_seconds", 0.01) + registry.observe("sentinel_event_latency_seconds", 0.60) + text = registry.render() + assert 'sentinel_event_latency_seconds_bucket{le="0.01"} 1' in text + assert 'sentinel_event_latency_seconds_bucket{le="+Inf"} 2' in text + assert "sentinel_event_latency_seconds_count 2" in text + + def test_drift_warms_up_then_tracks(self) -> None: + registry = MetricsRegistry(recent_window=5, baseline_window=10) + for score in range(5): + registry.observe_score(score) + assert "sentinel_score_drift 0.0" in registry.render() # still warming + for score in range(5, 10): + registry.observe_score(score) # baseline completes at mean 4.5 + for score in (90, 95, 85, 92, 88): + registry.observe_score(score) + text = registry.render() + # Baseline mean 4.5, recent mean 90.0 -> drift 85.5. + assert "sentinel_score_mean_recent 90.0" in text + assert "sentinel_score_drift 85.5" in text + + def test_render_never_raises_when_empty(self) -> None: + assert MetricsRegistry().render() + + +class TestServiceInstrumentation: + def make_client(self, tmp_path) -> TestClient: + settings = make_settings(tmp_path, tmp_path / "spool") + app = create_app( + settings=settings, + store=AuditStore(tmp_path / "audit.db"), + engine=VerdictEngine(), + ) + return TestClient(app, raise_server_exceptions=False) + + def test_request_id_echoed_and_generated(self, tmp_path) -> None: + client = self.make_client(tmp_path) + response = client.get("/healthz") + assert response.headers.get("X-Request-Id") + passed = client.get("/healthz", headers={"X-Request-Id": "fixed-id-42"}) + assert passed.headers["X-Request-Id"] == "fixed-id-42" + + def test_metrics_endpoint_requires_key_and_reports_ingest(self, tmp_path) -> None: + client = self.make_client(tmp_path) + assert client.get("/metrics").status_code == 401 + client.post("/v1/events", json=event_payload(1), headers=API_KEY) + body = client.get("/metrics", headers=API_KEY).text + assert "sentinel_events_total 1" in body + assert "sentinel_verdicts_total" in body + assert "sentinel_event_latency_seconds" in body + + def test_new_request_id_unique_and_short(self) -> None: + ids = {new_request_id() for _ in range(200)} + assert len(ids) == 200 + assert all(len(value) == 16 for value in ids) diff --git a/tests/test_postgres.py b/tests/test_postgres.py new file mode 100644 index 0000000..d751c7a --- /dev/null +++ b/tests/test_postgres.py @@ -0,0 +1,75 @@ +"""Postgres integration tests: the port/adapter swap, proven. + +Skipped locally unless SENTINEL_TEST_DATABASE_URL is set; CI runs them +against a real Postgres 16 service container, demonstrating that the +same AuditStore code serves both engines unchanged. +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime + +import pytest + +from sentinel.store import AuditStore + +pytestmark = [ + pytest.mark.postgres, + pytest.mark.skipif( + not os.environ.get("SENTINEL_TEST_DATABASE_URL"), + reason="SENTINEL_TEST_DATABASE_URL not configured (CI provides it)", + ), +] + +URL = os.environ.get("SENTINEL_TEST_DATABASE_URL", "") + + +@pytest.fixture() +def store() -> AuditStore: + return AuditStore(url=URL) + + +def test_event_roundtrip(store: AuditStore) -> None: + event = { + "event_id": f"pg-{datetime.now(UTC).timestamp()}", + "merchant_id": "mcht_pg", + "customer_id": "cust_pg", + "amount_paise": 42_000, + "upi_vpa": "pg@ybl", + "phone": "+919800000001", + "device_id": "dev_pg", + "email": None, + "ip": None, + "payment_method": "upi", + "prior_outcome": None, + "ts": datetime.now(UTC).isoformat(), + } + assert store.insert_event(event) is True + assert store.insert_event(event) is False # idempotent + stored = store.all_events() + assert any(row["event_id"] == event["event_id"] for row in stored) + + +def test_verdict_roundtrip_with_shadow(store: AuditStore) -> None: + verdict = { + "event_id": f"pgv-{datetime.now(UTC).timestamp()}", + "score": 91, + "verdict": "BLOCK_REC", + "reason_codes": ["RNG_DEVICE_FANOUT"], + "evidence": {"linked_merchants": ["m1", "m2"]}, + "features": {"device_identity_ratio": 6.0}, + "contributions": {"taint_propagation": 20.0}, + "model_version": "rules-v1.1", + "explanation_status": "PENDING", + "challenger": {"score": 88, "flag": True}, + } + store.insert_verdict(verdict) + fetched = store.get_verdict(verdict["event_id"]) + assert fetched is not None + assert fetched["score"] == 91 + assert fetched["challenger"]["flag"] is True + + +def test_healthy(store: AuditStore) -> None: + assert store.healthy() is True diff --git a/tests/test_story.py b/tests/test_story.py new file mode 100644 index 0000000..1832059 --- /dev/null +++ b/tests/test_story.py @@ -0,0 +1,49 @@ +"""Tests for the system-story parsers (real docs in /docs).""" + +from __future__ import annotations + +from pathlib import Path + +from sentinel.story import parse_adrs, parse_threats, parse_what_broke, system_payload + +DOCS = Path(__file__).resolve().parents[1] / "docs" + + +def test_what_broke_parses_real_doc() -> None: + entries = parse_what_broke(DOCS / "what-broke.md") + assert len(entries) >= 10 + first = entries[0] + assert set(first) == {"date", "phase", "broke", "cause", "fix"} + assert first["broke"] + + +def test_adrs_parse_real_doc() -> None: + adrs = parse_adrs(DOCS / "18-adrs.md") + assert len(adrs) == 6 + assert adrs[0]["id"] == "ADR-001" + assert adrs[-1]["id"] == "ADR-006" + assert all(adr["decision"] for adr in adrs) + + +def test_threats_parse_real_doc() -> None: + threats = parse_threats(DOCS / "16-threat-model.md") + assert len(threats) == 10 + assert threats[0]["class"] == "Spoofing" + assert all(t["control"] for t in threats) + + +def test_system_payload_shape() -> None: + payload = system_payload(DOCS, None) + assert payload["what_broke"] + assert payload["decisions"] + assert payload["threats"] + assert len(payload["components"]) == 7 + assert len(payload["disclosures"]) == 2 + assert payload["headline"]["precision"] is None + + +def test_parsers_skip_garbage(tmp_path: Path) -> None: + (tmp_path / "what-broke.md").write_text("not a table\n| a | b |\n", encoding="utf-8") + assert parse_what_broke(tmp_path / "what-broke.md") == [] + assert parse_adrs(tmp_path / "absent.md") == [] + assert parse_threats(tmp_path / "absent.md") == []