A configurable risk-scoring engine: arbitrary JSON in, a transparent score and decision out. Rules, field mappings, provider integrations and score thresholds are configuration — versioned, immutable once activated, and editable from a UI. No rule is hard-coded anywhere.
Backend: Go 1.22 · Gin · MongoDB · Redis Frontend: Vue 3 + TypeScript + Vite
See ARCHITECTURE.md for the design, the MongoDB data model and the execution flow.
| Phase | Scope | |
|---|---|---|
| 1 | Domain, database, assessment API, policy versioning, schema inspection | ✅ |
| 2 | Mapping engine — extensible transform registry, type coercion | ✅ |
| 3 | Rule engine — recursive condition trees, 12 operators, full traces | ✅ |
| 4 | Score / policy engine — action semantics, clamping, flag escalation | ✅ |
| 5 | Integration framework — timeouts, retries, circuit breaker, SSRF guard, masking | ✅ |
| 6 | Webhook processing — HMAC verification, dedupe, assessment resume | ✅ |
| 7 | Versioning + audit trail | ✅ |
| 8 | Vue 3 console with visual rule builder | ✅ |
| 9 | Test playground with full execution trace | ✅ |
| 10 | Hardening — idempotency, rate limits, deadline sweeper, tenant isolation | ✅ |
cp .env.example .env
make upThat builds and starts MongoDB (single-node replica set), Redis, the API, the Vue console and a mock third-party provider, then seeds a demo tenant, an API key and a working policy.
- Console — http://localhost:5173
- API — http://localhost:8080
- Demo API key —
rsk_demo_local_key
Without Docker:
docker run -d -p 27017:27017 mongo:7 mongod --replSet rs0 --bind_ip_all
docker exec $(docker ps -qf ancestor=mongo:7) mongosh --eval "rs.initiate()"
go mod tidy && go run ./cmd/seed && go run ./cmd/api
cd web && npm install && npm run devSix steps, each backed by real API calls — nothing in the UI is simulated in local state.
- Input schema — paste any JSON; the engine reports its field tree and suggests mappings.
- Field mapping — map source paths onto normalized signals, with transform pipelines.
- Integrations — configure providers: endpoint, auth (by secret reference), timeout, retry, request/response mapping, webhook settings.
- Rules — a recursive visual builder. Signals are picked from a list built out of your mappings and provider response mappings, so a typo can't silently become a rule that never matches.
- Score policy — thresholds with live gap/overlap checking, saved as a new immutable version.
- Test — run an assessment and see the score, the decision, every triggered rule with its condition traces, the score arithmetic line by line, the normalized signals with provenance, the mapping trace, and the stage-by-stage execution trace.
/v1 routes require Authorization: Bearer <api key>. The tenant comes from the key, never the body.
POST /v1/assessments run a scoring request
GET /v1/assessments?status=&decision=&limit=&offset= list
GET /v1/assessments/:id?include=trace,input,mapping fetch with execution detail
GET /v1/assessments/:id/trace execution trace
POST /v1/test-assessment same pipeline, flagged as a test
POST /v1/risk-policies create a policy
GET /v1/risk-policies
GET /v1/risk-policies/:id
POST /v1/risk-policies/:id/versions create a version (optionally activate)
GET /v1/risk-policies/:id/versions
GET /v1/risk-policies/:id/versions/:versionId
POST /v1/risk-policies/:id/versions/:versionId/activate
POST /v1/integrations configure a provider
GET /v1/integrations
GET /v1/integrations/:id
PUT /v1/integrations/:id
POST /v1/webhooks/:provider provider callback (HMAC authenticated)
POST /v1/schema/inspect discover fields in any payload
GET /healthz /readyz
POST /v1/assessments honours Idempotency-Key: a retry replays the stored response instead of
scoring — and paying a provider for — the same request twice.
curl -s -X POST localhost:8080/v1/assessments \
-H 'Authorization: Bearer rsk_demo_local_key' \
-H 'Idempotency-Key: order_8891' \
-H 'Content-Type: application/json' \
-d '{
"policy_key": "default",
"input": {
"user": {"id":"123","country":"US","age":30},
"transaction": {"amount":15000,"currency":"USD"}
}
}'The response always explains itself — a bare {"score": 95} is never returned:
{
"assessment_id": "asmt_2f1a...",
"status": "COMPLETED",
"score": 90,
"decision": "DECLINE",
"decision_reason": "score within threshold band",
"policy": {"policy_key":"default","version":1,"hash":"9c4f2b1e77aa"},
"rules_evaluated": 4,
"rules_triggered": [
{"rule_id":"high_amount","action":"ADD_SCORE","score":30,
"reason":"Transaction amount is above 10000",
"conditions":[{"field":"transaction.amount","op":">","expected":10000,
"actual":15000,"present":true,"matched":true}]},
{"rule_id":"high_risk_country","action":"ADD_SCORE","score":20,"reason":"..."},
{"rule_id":"third_party_fraud","action":"ADD_SCORE","score":40,"reason":"..."}
],
"breakdown": {
"entries": [
{"rule_id":"high_amount","delta":30,"running":30},
{"rule_id":"high_risk_country","delta":20,"running":50},
{"rule_id":"third_party_fraud","delta":40,"running":90}
],
"raw_score": 90, "score": 90, "clamped": false
},
"signals": [
{"key":"customer.country","value":"US","source":"INPUT"},
{"key":"third_party.acme.fraud_score","value":90,"source":"PROVIDER","provider":"acme"}
],
"duration_ms": 34
}That third rule fires on a signal the seeded mock provider returned, so the run above exercises the whole chain: mapping → provider call → response mapping → rules → score → decision.
Switch the seeded provider to "mode": "ASYNC" and point its path at /score-async:
POST /v1/assessments → 202 { "status": "AWAITING_SIGNALS", "pending_providers": ["acme"] }
mock provider → POST /v1/webhooks/acme (HMAC-signed, carries client_reference)
→ assessment resumes, rules run, decision is stored
GET /v1/assessments/:id → COMPLETED, with the webhook stage in the trace
Every callback is authenticated by HMAC over the raw body with a timestamp window, deduplicated by a
unique index on (provider, event_id), and persisted before it is processed. Duplicate delivery is a
200 no-op. If the callback never comes, a sweeper expires the assessment rather than leaving it parked
forever.
cmd/api service entrypoint (index bootstrap, sweeper, graceful shutdown)
cmd/seed demo tenant, API key, provider and worked-example policy
cmd/mockprovider stand-in third-party provider (sync + webhook)
internal/domain rules, signals, scoring, thresholds, versioning <- stdlib only, no I/O
internal/port interfaces the application layer depends on
internal/ruleeval condition-tree evaluator (pure)
internal/app use cases: assessment pipeline, policy, integrations, webhooks, sweeper
internal/app/mapping transform registry and mapping engine (pure)
internal/adapter/mongox MongoDB repositories, index bootstrap, transactions
internal/adapter/httpapi Gin router, auth, idempotency, rate limiting, handlers
internal/adapter/providerhttp provider client: SSRF-safe transport, retry, circuit breaker
internal/adapter/secrets secret-reference resolution (env://, aws-sm://, vault://)
internal/adapter/sys clock, id generator, token-bucket rate limiter
internal/pkg/jsonpath dotted-path access and payload field discovery
internal/pkg/mask sensitive-data redaction for logs and stored payloads
web/ Vue 3 + TypeScript console
The layering is verifiable, not aspirational: internal/domain imports only the standard library, the
MongoDB driver appears only under adapter/mongox, and Gin only under adapter/httpapi.
make test # unit tests
make test-integration # needs a live MongoDB replica set
make coverLatest run: all packages pass, 28.2% overall statement coverage (dragged down by
untested adapter/wiring packages — cmd/api, internal/config, internal/pkg/mask,
etc. have no test files). Coverage where it matters is much higher:
| Package | Coverage |
|---|---|
internal/pkg/jsonpath |
94.9% |
internal/ruleeval |
75.0% |
internal/app/mapping |
69.7% |
internal/adapter/sys |
63.3% |
internal/domain |
53.6% |
internal/app |
34.5% |
The suites concentrate on the places where a bug is expensive:
- scoring —
ADD/SUBTRACT/SET/FLAGsemantics, running totals, clamping, and the rule that errored or skipped rules never contribute silently; - thresholds — band resolution, flag escalation that never downgrades a decision, rejection of gaps and overlaps;
- rule engine — all 12 operators, nested AND/OR/NOT, int-versus-float comparison across JSON and config, null-versus-missing signals, and a broken rule erroring instead of scoring zero;
- mapping — transform pipelines, currency conversion, date fallbacks, registry extensibility, declared types overriding payload types, and required fields failing closed;
- webhooks — signature verification, tampered bodies, wrong secrets, replay outside the window;
- SSRF — plain HTTP, embedded credentials, link-local and metadata hosts;
- pipeline — raw input paths never reaching the signal bag, ordered traces, async parking before any rule runs;
- integration (live Mongo) — one ACTIVE version per policy across repeated activations, transactional pointer updates, archived versions staying readable, optimistic-concurrency conflicts, webhook deduplication, and cross-tenant reads being refused.
- Why a snapshot per version.
policy_versionsembeds mappings, rules and thresholds with a content hash. Scoring reads one document; re-reading a year-old assessment reproduces the exact configuration that produced it. Therulescollection is an authoring library only. - Why signals are an array, not a map. Signal keys are dotted paths and BSON keys cannot contain dots.
- Why a replica set locally. Activation archives the previous version, promotes the new one and moves the policy pointer in one transaction. A partial unique index makes "two active versions" unrepresentable regardless.
- Fail-open vs fail-closed is configuration. Each integration carries
on_error(IGNORE/DEFAULT_SIGNAL/FAIL_ASSESSMENT). Either way the failure appears in the trace. - Secrets. Provider configuration stores
secret_refonly; values resolve at call time. API keys are stored as SHA-256 hashes. ALLOW_INSECURE_PROVIDERSexists so the local mock provider can be reached over plain HTTP on a private address. It disables part of the SSRF protection and must stayfalsein production.
