# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Code style - Code must be easy for a human to understand first: plain naming, straightforward control flow, standard idioms for the language and framework. No cleverness that needs a comment to decode. - If less code solves the problem equally well, prefer less code — but never buy brevity at the cost of readability. ## Repository Layout This is a polyglot monorepo with **four independent backend services** that share PostgreSQL/Redis/Kafka infrastructure, plus a separate frontend SPA. Each service has its own dependencies and build: - **Root (`src/`)** — RDA (Real-Time Detection Agent), TypeScript/Fastify HTTP API. Owns the `package.json` at the repo root, the Knex migrations under `src/database/migrations/`, and the shared ONNX model under `models/`. - **`paa-service/`** — PAA (Pattern Analysis Agent), TypeScript Kafka consumer worker. Has its own `package.json`, `tsconfig.json`, and `node_modules`. Not invoked from the root scripts. - **`mla-service/`** — MLA (Model Learning Agent), Python 3.11 service. Has its own `requirements.txt` and `venv`. Trains XGBoost → ONNX models that are deployed by copying into `models/fraud_model.onnx` for RDA. - **`fia-service/`** — FIA (Fraud Investigation Agent), Python 3.11 service. Consumes the `transactions.blocked` Kafka topic, runs a fine-tuned Phi-3-mini-4k-instruct LLM, and writes structured reports to PostgreSQL `investigationReports`. Strictly async — never on the RDA authorization path. - **`frontend/`** — Sentinel operator dashboard, Vite + React 18 SPA. Has its own `package.json` and `node_modules`. Not invoked from the root scripts. Talks to RDA `/v1/admin/*` and FIA `/v1/reports*`; when those services are unreachable every read returns an empty fallback and the SPA shows a persistent OFFLINE banner — no synthetic data. The old `mock.js` seed and `sentinel.useMock` override were removed in May 2026. RDA is the only producer. PAA and MLA consume `transactions.completed`; FIA consumes `transactions.blocked` (published by RDA only when the decision is `DECLINE`). All four services share the same Postgres `fraud_db`. ## Common Commands ### RDA (root) ```bash npm run start:dev # nodemon hot-reload npm run build # tsc; postbuild copies *.yaml into dist/ npm run lint # eslint over .ts npm run test # jest --runInBand --passWithNoTests npx jest path/to/file.test.ts # single test file npx jest -t "test name" # single test by name npm run db:migrate # knex migrate:latest (uses dotenv) npm run db:migrate:make -- name_of_migration npm run db:migrate:rollback ``` ### PAA (`paa-service/`) PAA has its own deps and tsconfig — `cd paa-service` first. Same `start:dev` / `build` / `lint` / `test` script names. The root `npm install` does **not** install PAA deps. ### FIA (`fia-service/`) ```bash cd fia-service && source .venv/bin/activate # NOTE: .venv (FIA), not venv (MLA) python -m src.main # consume + generate reports ``` Health: `:9094/livez`, `:9094/readyz`, `:9094/stats`. The first run downloads ~7.6 GB of Phi-3-mini-4k-instruct weights to `~/.cache/huggingface`. Set `FIA_FALLBACK_ON_LLM_FAILURE=true` (default) to degrade gracefully to a deterministic rule-based report when the LLM cannot load — the pipeline still produces parseable rows. Device selection: `LLM_DEVICE=auto` picks CUDA → MPS → CPU; on Apple Silicon expect ~45 s model load and a one-time ~6–10 min MPS kernel compilation on the first generation, then ~40–90 s per report steady-state. ### MLA (`mla-service/`) ```bash cd mla-service && source venv/bin/activate python -m src.main # start drift-monitoring loop python -m src.main --train # force retrain on startup python scripts/train_initial_model.py # cold-start training python scripts/train_initial_model.py --samples 10000 --skip-registry pytest # all tests pytest tests/test_onnx_conversion.py -v # single file ``` After training, deploy the model to RDA with `cp models/fraud_model_v1.0.onnx ../models/fraud_model.onnx`. ### Frontend (`frontend/`) The Sentinel dashboard has its own deps and build — `cd frontend` first. The root `npm install` does **not** install frontend deps. ```bash cd frontend && npm install npm run dev # vite dev server on :5173 npm run build # production bundle into dist/ npm test # vitest run (jsdom + Testing Library) npm test -- tests/sidebar.test.jsx # single file ``` The dev server proxies `/v1/*` → `VITE_RDA_URL` (default `http://localhost:3000`) and `/fia/*` → `VITE_FIA_URL` (default `http://localhost:9094`, prefix stripped). Auth is read from `localStorage` at request time — `sentinel.jwt` becomes `Authorization: Bearer …` (the user JWT from `POST /v1/auth/login`), `sentinel.apiKey` becomes `X-Api-Key`. See [`docs/FRONTEND.md`](docs/FRONTEND.md) for the full reference. ### Infrastructure ```bash docker compose up -d redis postgres kafka zookeeper # infra only docker compose -f docker-compose.yml -f docker-compose.dev.yml up rda-dev paa-dev --build # dev with hot-reload docker compose up --build # full prod stack (N× RDA via RDA_REPLICAS (default 3), 1× PAA singleton, NGINX, Prometheus, Grafana) ``` Postgres in Docker listens on **5433** (not 5432) to avoid host conflicts. **Dev module resolution (OJR-45, fixed).** Compiled builds register `module-alias` (aliases → `dist/`); under ts-node, `src/register-aliases.ts` skips that registration so `tsconfig-paths` resolves aliases to `src/*.ts` — one module universe either way (mixing the two resolvers would duplicate every tsyringe singleton). Dev containers therefore run the mounted source directly; no in-container `npx tsc` is needed after edits. nodemon runs with `legacyWatch` (polling) so host-side edits through the Docker mount trigger restarts on macOS. One remaining quirk: a fenced PAA exit reads as a crash to nodemon, which waits for a file change instead of restarting. ## Architecture Notes That Aren't Obvious from One File ### Path aliases differ per service The root and PAA both use TS path aliases but they map to different roots: - Root: `@config/*` → `src/config/*`, `@shared/*` → `src/shared/*`, `@utils/*` → `src/shared/utils/*` - PAA: `@config/*` → `paa-service/src/config/*`, `@services/*` → `paa-service/src/services/*`, `@utils/*` → `paa-service/src/utils/*` Both rely on `module-alias` at runtime (resolving against `dist/`) and on the `paths` config in their respective `tsconfig.json` at compile time. Jest's `moduleNameMapper` mirrors this — when adding a new alias, update both tsconfig and jest.config. ### DI container Both TS services use `tsyringe` with `reflect-metadata` imported as the very first line of the entry point. Services are `@injectable()` and resolved via `container.resolve(...)`. `KafkaProducer` is resolved at startup in `src/server.ts` and connection failure is non-fatal (logs warn, retries on first publish). ### Real-time path (RDA) vs async path (PAA) RDA's `predict` flow is: Redis feature lookup → ONNX inference → Kafka publish. **Features in Redis are stale until PAA writes them.** On Redis cache miss, RDA falls back to default features and logs a degraded-accuracy warning — this is expected behavior, not a bug. PAA is the writer: it consumes `transactions.completed`, updates an in-memory transaction graph (`graphology`) and velocity windows, then queues batched writes to Redis and Postgres. Graph metadata is snapshotted for both sender and receiver on every event into a Map keyed by `userId`, then bulk-upserted to Postgres on the standard batch flush (size 100 / 10 s) — the Map dedupes hot users so Postgres pressure tracks unique-user rate, not event rate. Redis writes feed the next RDA prediction. **PAA is a singleton — do not scale it.** The graph and velocity state live in process memory. A second member in the `pattern-analysis` consumer group splits the partition assignment, so each replica runs PageRank/Louvain on a partial graph and rings whose members hash to different partitions become invisible. PAA takes a Redis leader lease (`ojuri:paa:leader`, `PAA_LEADER_LEASE_TTL_MS`, default 30 s) before it starts consuming; a second instance waits up to `PAA_LEADER_ACQUIRE_TIMEOUT_MS` for handover, then exits rather than joining the group. Two properties matter and are easy to get wrong: - **Renewal fails closed.** An unreachable Redis is not "still the leader" — the key expires server-side regardless, so a partition outlasting the TTL means a challenger has already taken over. The lease surrenders on elapsed time, not only on a confirmed loss. - **A fenced-out instance discards its buffers.** Its graph is by definition partial, and `redisUpdateService`/`postgresService` would otherwise flush that snapshot over what the new leader has already written. `stop({ discard: true })` drops them; the flushing path is for SIGTERM only. This is a lease, not a fencing token: it cannot stop a process paused past the TTL from issuing one last write. `PAA_REQUIRE_LEADER_LEASE=false` disables the fence. The `paa_group_members` gauge and the ERROR log remain as backstop observability. ### Blocked-transaction investigation path When `PredictService` returns `decision === "DECLINE"`, RDA publishes the same `TransactionEvent` to **two** topics fire-and-forget: the primary `transactions.completed` (consumed by PAA + MLA, partitioned by `sender_id` for per-user ordering) and `transactions.blocked` (consumed only by FIA, partitioned by `transaction_id` so a single high-fraud sender does not pin all FIA work to one partition). The dual publish is intentional — FIA runs at LLM-inference latencies (seconds) and must never share a queue with PAA's millisecond pipeline. `KafkaProducer.publishAsync(event, topic?, partitionKey?)` is the entry point. The LevelDB disk buffer wraps each entry as `{ v: 2, topic, partitionKey, event }` so flushed events replay to the original topic with the original partition assignment. Legacy raw entries (pre-refactor) fall back to the primary topic with `sender_id` keying. `flushBuffer` continues past per-entry failures — a stuck entry on one topic must not block flush progress on others. FIA-side idempotency: `investigationReports.transactionId` is UNIQUE and the writer uses `INSERT ... ON CONFLICT DO NOTHING`. The Kafka consumer commits offsets **per-partition** (never `consumer.commit()` with no args, which would advance offsets across partitions). LLM generation runs synchronously in the message handler, so the consumer is configured with `max_poll_records=1` and `max_poll_interval_ms=600000` to prevent rebalances during a slow LLM call. Poison messages are bounded by an in-memory retry counter (`MAX_RETRIES=3`); after that the offset is committed and the failure is logged loudly so a true bad message cannot wedge a partition forever. ### MLA closes the loop offline MLA monitors windowed F1 and PSI (`DRIFT_PSI_THRESHOLD=0.25`; the F1 threshold is anchored to the deployed champion's validation F1 minus `DRIFT_F1_MARGIN`, falling back to `DRIFT_F1_THRESHOLD=0.4` when no champion metrics exist). Drift windows are fed from Postgres ground truth on the label poll — RDA never publishes labelled events. Retrains also fire on `LABEL_RETRAIN_THRESHOLD` (500) new verified labels; that watermark is anchored to the last `succeeded` row in `retrainRuns`, so labels that arrive while MLA is down still count after a restart. On retrain it applies SMOTE (calibration split carved pre-augmentation), runs McNemar's test against the current model, and only deploys if the improvement is statistically significant. Output is `.onnx` + `_scaler.npz` + `meta.json` (isotonic breakpoints + reason weights); the scaler must be loaded alongside the model. All triggers share `RETRAIN_COOLDOWN_SECONDS` (default 6 h). ### Resilience RDA wraps Redis feature retrieval and ONNX inference in `opossum` circuit breakers (see `src/shared/circuit-breaker/`). When breakers open, predictions still succeed but use defaults — design for graceful degradation, not failure. **The ONNX breaker fallback is not a DECLINE.** opossum fires the fallback on every failure *including a per-call timeout*, so `CB_ONNX_TIMEOUT` sitting below the service's own measured p95 under concurrency turned ordinary contention into customer-facing declines. The timeout now defaults to 750 ms (error threshold 25%), and the fallback returns `{ degraded: true }` rather than a bare `1.0`. `PredictService` maps that to `CB_ONNX_FALLBACK_DECISION` (default `REVIEW` — route the unscored transaction to a human, don't decline a customer on infrastructure failure) and stamps `decisionSource = BREAKER_FALLBACK`, so an audit row can distinguish "the model scored 1.0" from "inference never ran". Degraded declines are **not** published to `transactions.blocked`: there is no model signal for FIA to investigate, and during an outage every request would otherwise queue an LLM report. ### Health endpoints - RDA: `GET /livez`, `GET /readyz`, predict at `POST /v1/predict`, metrics at `GET /v1/metrics` (route version is `/v1`, **not** `/api/v1`). - PAA: standalone HTTP server on `METRICS_PORT` (default 9090) exposing `/livez`, `/readyz`, `/metrics`, `/stats`. Defined inline in `paa-service/src/worker.ts` — not a Fastify app. - FIA: HTTP server on `METRICS_PORT` (default 9094) exposing `/livez`, `/readyz`, `/stats` (counters: processed, duplicates, failed, dropped_poison, in_flight_retries, llm_model). Defined inline in `fia-service/src/main.py` — not a Fastify/Flask app. ### Frontend dashboard The Sentinel SPA under `frontend/` ports the Claude Design handoff into ES-module React. Two non-obvious patterns matter when editing it: - **`safe()` fallback wrapper.** Every read in `frontend/src/api/client.js` is wrapped: `safe(() => fetch(...), () => [])`. The fallback is always an empty value (`[]`, `{ rows: [], total: 0 }`, `null`) — no `mock.js`, no synthetic rows. When the backend is offline or 401s, the call returns that empty fallback and the page renders an empty state; `app.jsx` flips a persistent `OFFLINE` banner. Write calls (issue key, save rule, …) do **not** use `safe` — they `try` the real call, catch failures locally, surface a toast, and leave the form in its previous state so the operator can retry. New endpoints should follow the same split: reads via `safe`+empty-fallback, writes try-locally-toast. - **Auth lives in `localStorage`, read per-request.** `adminHeaders()` and `apiHeaders()` in `client.js` re-read `sentinel.jwt` / `sentinel.apiKey` on each call. The JWT is obtained via `POST /v1/auth/login` and authenticates identity only — the server resolves permissions from Postgres per request through a 30 s cache, so role changes and user deactivation apply to live sessions within that window (see `docs/AUTHZ.md`). `RDA_REQUIRE_API_KEY=true` on the backend will 401 all predict calls until `sentinel.apiKey` is set. The dashboard never sits on the prediction hot path: it issues admin reads and rare writes only. Adding a new page only needs three touch points: a new file under `frontend/src/pages/`, the route id in `loadRoute()` in `frontend/src/app.jsx`, and a `Sidebar` entry in `frontend/src/components/shell.jsx`. ## ONNX Compatibility (MLA only) XGBoost → ONNX conversion is broken in newer onnxmltools/onnx releases. `mla-service/requirements.txt` pins: ``` onnx==1.13.0 onnxmltools==1.10.0 onnxconverter-common==1.12.0 ``` If you see `TypeError: Field onnx.AttributeProto.ints: Expected an int, got a boolean`, reinstall these pinned versions. Do not bump them without testing the full training → ONNX → RDA inference path end-to-end. ## Database - Single Postgres database `fraud_db`, owned by RDA's Knex migrations under `src/database/migrations/`. PAA and MLA read/write the same tables but do **not** own migrations — schema changes go through the root. - The `knexfile.js` exposes `primary` and `secondary` pool configs (read replica via `REPLICA_DB_*` env vars, falls back to primary if unset). - Training requires non-null `fraudLabel` values in the `transactions` table. If absent, MLA's training script falls back to synthetic data (logged as a warning — fine for dev, not for production results). ## Adoption Features (added in 2026-05 revision) These extend RDA without changing the real-time hot path. They all live under `src/shared/` so they can be reused by PAA or future workers. - **API-key auth (`src/shared/auth/`)** — `ApiKeyService` issues `fdk__` tokens, persists only the SHA-256 hash, caches verification for 30 s. The `apiKeyMiddleware` plugs into Fastify `preHandler`; `RDA_REQUIRE_API_KEY=true` flips the predict endpoint from open to authenticated. - **User auth + RBAC (`src/shared/authz/`)** — bcrypt-hashed users, roles with permission arrays, JWT sessions. Permission catalogue is **code-defined** in `permissions.ts` (no migration to add a new code). Migration seeds `SUPER_ADMIN` + `admin/admin@fraudit`. The `requireAuth(...perms)` middleware (`src/shared/middlewares/require-auth.middleware.ts`) guards every `/v1/admin/*` route; the static `RDA_ADMIN_TOKEN` was retired in 2026-05. - **Rules engine (`src/shared/rules/`)** — JSON-Logic-style evaluator (no arithmetic, just predicates / combinators / `in` / `var`). Rules are hot-reloaded from Postgres every `RULES_RELOAD_INTERVAL_MS` (30 s default). `stage` is `PRE` (short-circuits ML) or `POST` (overrides ML). Two seeds ship by default: `01_demo_rules.ts` (PRE rules used by the demo dataset) and `03_fatf_rule_pack.ts` (FATF: structuring, VPN+amount, high-risk corridor TRANSFER, ATO signature, untrusted device + amount — NGN-tuned defaults that adopters should review per-market). - **Model registry (`src/shared/models/model-registry.service.ts`)** — stores `modelVersions` and `segmentThresholds`; resolves `(segment) → (champion, shadow, threshold)` for each request. Status transitions: CANDIDATE → SHADOW → ACTIVE → RETIRED. The ONNX session itself is still loaded by `OnnxService`; the registry is metadata + threshold routing only. Per- transaction-type threshold defaults (CASH_OUT=0.70, TRANSFER=0.30, PAYMENT=0.50, DEBIT=0.50, CASH_IN=0.50) are seeded via `02_segment_thresholds.ts` once an ACTIVE model is registered. Lookup uses `request.segment ?? request.transaction_type`. Activating a version hot-reloads it: `OnnxService` loads the version artefact directly and treats the copy into the canonical `MODEL_PATH` as best-effort (the compose files mount `models/` read-only, so EROFS is normal), then re-runs both health probes before serving. A champion that was already ACTIVE before boot is applied at startup the same way — cold restarts do not silently fall back to whatever the canonical file holds. - **Decision audit log (`src/shared/audit/`)** — every `/v1/predict` writes a row to `decisionAuditLog` with model versions, scores, threshold, rule hit, reason codes, feature snapshot, and reviewer fields. Default pipeline is the in-memory batch queue (row durable at flush; backpressure → 503 at 50k; `AUDIT_SYNC_WRITE=true` for persist-before-respond). `AUDIT_PIPELINE=stream` switches to the log-first prototype: the decision event carries the full audit payload, is published with an awaited `acks=all` send before the response, and `AuditStreamConsumer` materialises this table from the topic; late values (shadow scores, early-PRE snapshots) follow as `audit.enrichments` events applied as idempotent UPDATEs. Measurements and adoption gaps: `docs/LOG_FIRST_AUDIT_PROTOTYPE.md`. - **Reason codes (`src/shared/onnx/reason-codes.ts`)** — lightweight feature-deviation explainer for the 12 named feature positions. Cheap enough to compute on every prediction. Weight *magnitudes* come from the deployed model's gain importances (`meta.json` → `reason_weights`, emitted by MLA at registration); the *sign* stays with the hand-written spec, since importances are unsigned. Each code carries a `basis` of `MODEL_WEIGHTED` or `HEURISTIC` so an investigator knows which they are reading. Neither is per-transaction attribution — for that, and for narrative reasoning, use the FIA endpoints (`POST /v1/reports`, `/messages`). - **Webhooks (`src/shared/webhooks/`)** — HMAC-signed POST with exponential backoff. `WebhookService.publish(event, payload, tenantId)` enqueues rows in `webhookDeliveries`; the in-process worker (started from `server.ts`) drains pending rows every `WEBHOOK_WORKER_INTERVAL_MS`. - **Idempotency (`src/shared/idempotency/`)** — `Idempotency-Key` header on `/v1/predict` is keyed against `(tenantId, key, requestHash)`. Replay returns the cached response with `Idempotency-Replay: true`. Body divergence on the same key returns 422. - **Training-data ingest (`src/v1/modules/training/`)** — adopter CSVs enter the system through either `POST /v1/admin/training/import` (file:// source for ops-driven imports) or the Sentinel chunked-upload protocol (`upload/init`, `chunk`, `complete`, `abandon`). Rows land in `transactionsStaging` keyed by `jobId`. `POST /v1/admin/training/import/ :jobId/promote` upserts them into `transactions` with `groundTruthSource = 'training_import'`, then `POST /mla/v1/admin/retrain` kicks off a retrain. New tables: `trainingJobs`, `trainingUploads`, `transactionsStaging`. See `docs/ADOPTER_TRAINING.md`. - **Score calibration (MLA, `mla-service/src/training/calibration.py`)** — XGBoost saturates near 0.0/1.0; we fit `sklearn.IsotonicRegression` on a calibration split carved from the training block **before** SMOTE and context-dropout augmentation (fitting it on oversampled rows targets a ~50% synthetic base rate, not the real one). The isotonic breakpoints are written to `meta.json` as a `calibration` block — the paired `calibrator.npz` is numpy-native and unreadable from Node, which is why calibration previously affected only the reported Brier and never a served score. RDA loads the breakpoints in `OnnxService` and applies them after ONNX output. **`ONNX_CALIBRATION_MODE` defaults to `observe`**: the calibrated score is recorded in `decisionAuditLog.calibratedScore` while decisions still use the raw score. Every threshold (0.65 default, 0.70 CASH_OUT, 0.30 TRANSFER) was tuned against the raw distribution, so flipping to `enforce` before re-deriving them from calibrated audit data would move every decision boundary at once. - **Configurable training mode (`mlaSettings.trainingMode`)** — operators pick `FRESH` (current behaviour, train from scratch) or `CONTINUED` (seed from current production model via XGBoost `xgb_model=`, add `continuedTreesPerRound` trees). Setting persists via `PUT /mla/v1/admin/drift-config`. MLA reads it at retrain start; in-flight runs keep their loaded mode. FIA gained an HTTP API on `:9094`: `POST /v1/reports` (on-demand reports for any transaction, idempotent by `transactionId`), `POST /v1/reports/:id/messages` (conversational follow-ups persisted in `investigationConversations`), `GET /v1/reports[/:id]` (list / read). ## Feature catalogue (replaces the legacy 434-dim prototype pipeline) Features are now **declaratively contracted** in `models/feature-catalog.v1.json` — 64 base features across 9 categories (velocity / pair / graph / transaction / identity / receiver / geographic / device / calendar). Adopters add their own features via `models/feature-catalog.adopter.json` overlay using a small algebra of compute ops (`from_field`, `equals`, `is_one_of`, `ratio`, `lookup`, `numeric_bucket`, `bool_and/or`, `from_redis`). See `docs/FEATURES.md`. Both RDA (TS — `src/shared/features/`) and MLA (Python — `mla-service/src/features/`) load the same JSON catalogue and validate identical invariants. `feature_schema_version` is baked into every model's `meta.json` and enforced at load time by `OnnxService` — a model trained against a different overlay is **refused** rather than silently misaligned. `MODEL_INPUT_DIMENSION` env enables pad-to-fit during the brief Phase-2/Phase-3 transition where RDA produces 64-dim but the deployed model still expects more. The previous "PROTOTYPE MODE: USING PLACEHOLDER FEATURES" warnings and 411-zero-padding have been removed. Training and inference dimensions now match exactly. ## Reference Performance (single developer workstation, Apple Silicon) Orientation values, not SLA targets. Re-measure on your own hardware. **Before quoting any p99, read [Benchmarking traps to avoid](docs/ARCHITECTURE.md#8-performance-characteristics).** Two shipped defaults — the NGINX rate limit and the idempotency duplicate short-circuit — return fast responses without ever running the model, so a naive bench measures the reject path. That section is canonical; it is not restated here, because the last time it was, the two copies drifted and this file kept asserting a superseded p99. **Measured values** (single RDA replica, direct port 3000 to bypass NGINX, unique `transaction_id` per request, all 200 OK): - ONNX model only (deployed PaySim model, 122 KB, batch=1): p50=0.010 ms, p99=0.049 ms. - RDA `/v1/predict`, single client, uncontended: p99 ≈ 6 ms (idempotency reservation + audit enqueue are now on the hot path; the earlier 4 ms figure predates those two stages). - RDA `/v1/predict`, 16 concurrent, 2,000 trials, post-1.4.0 review fixes: p50=28.9 ms, p95=51.7 ms, **p99=84.5 ms**, ~516 RPS, zero breaker fallbacks. This supersedes the pre-fix 5,000-trial run (mean 35 ms, p50=43 ms, p95=140 ms, p99=295 ms, p999=3.3 s, ~237 RPS), whose per-stage means were `feature_load` 19 ms, `inference` 16 ms, and sub-1 ms for the non-ML stages (rules, reason codes, audit_enqueue) — the two ML stages an order of magnitude above their uncontended cost because Node's event loop was serialising async resolutions under contention. Per-stage means have not been re-measured since the fixes, so they describe the old regime only. - IEEE-CIS XGBoost training: 683,852 train / 118,108 test in 27.67 s; held-out F1=0.554, AUC=0.911. - FIA (Phi-3-mini, MPS, fp16): ~46 s LLM load, ~6–10 min one-time MPS warmup, then ~40–90 s per report. Idempotency guard verified end-to-end. The 84.5 ms p99 number is the current honest baseline at meaningful concurrency on this hardware. Driving it down toward the uncontended 6 ms is open work — request admission control at the predict route, hot-path object-allocation cleanup, possibly cluster-mode workers per container, and a benchmark host separate from the server are the levers worth trying next.