Reglas avanzadas
Concepto completo del motor de reglas, predicados y semantics.
Concepto →bhawk es el módulo de cumplimiento del ecosistema. Te permite crear sets de reglas SARLAFT versionadas, cruzar contra OpenSanctions (OFAC, ONU, PEP, EU_SANCTIONS, FATF) y operar el workbench de casos para review/reject.
En este quickstart vas a:
POST /v1/screenings) que persiste y debita 8 Açaí.export DJ_BASE=https://bhawk.qa.bjungle.netexport DJ_API_KEY=dj_test_... # misma key que bmonkeybhawk corre en su propio host (bhawk.qa.bjungle.net), pero usa la misma
API key del tenant.
@bjungle/sdk (TypeScript)El SDK enruta cada módulo a su host por separado a partir de baseUrl
o moduleUrls. Para integraciones nuevas usá el SDK — tipa request +
response, mapea 409/402/422 a errores con clase y agrega
Idempotency-Key automático.
npm install @bjungle/sdkimport { BjungleClient } from '@bjungle/sdk';
const dj = new BjungleClient({ apiKey: process.env.DJ_API_KEY!, baseUrl: 'https://api.qa.bjungle.net', moduleUrls: { bhawk: 'https://bhawk.qa.bjungle.net', // mismo tenant, host distinto },});Una validation es un grupo versionado de reglas. Ciclo de vida:
draft → published → deprecated. Solo los drafts se pueden mutar.
const validation = await dj.bhawk.validations.create({ code: 'default', name: 'SARLAFT por defecto', description: 'Reglas baseline para personas naturales CO',});// { id, code, status: 'draft', version: 1, ... }curl -X POST "$DJ_BASE/v1/validations" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "code": "default", "name": "SARLAFT por defecto", "description": "Reglas baseline para personas naturales CO" }'const res = await fetch(`${DJ_BASE}/v1/validations`, { method: 'POST', headers: { 'X-API-Key': DJ_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ code: 'default', name: 'SARLAFT por defecto', description: 'Reglas baseline para personas naturales CO', }),});const validation = await res.json(); // {id, code, status:'draft', version:1, ...}body, _ := json.Marshal(map[string]string{ "code": "default", "name": "SARLAFT por defecto", "description": "Reglas baseline para personas naturales CO",})req, _ := http.NewRequest("POST", base+"/v1/validations", bytes.NewReader(body))req.Header.Set("X-API-Key", apiKey)req.Header.Set("Content-Type", "application/json")res, _ := http.DefaultClient.Do(req)defer res.Body.Close()Capturá validation.id para el próximo paso.
bhawk trae un catálogo de plantillas SARLAFT canónicas (PEP, OFAC hit, edad mínima, países de alto riesgo, etc.):
curl -X POST "$DJ_BASE/v1/rules/seed-defaults" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"validation_id\": \"$VALIDATION_ID\", \"subject_type\": \"natural\" }"await fetch(`${DJ_BASE}/v1/rules/seed-defaults`, { method: 'POST', headers: { 'X-API-Key': DJ_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ validation_id: validation.id, subject_type: 'natural', }),});Inspeccioná el catálogo crudo si querés filtrar antes de seedear:
curl "$DJ_BASE/v1/templates?subject_type=natural&group=sarlaft" \ -H "X-API-Key: $DJ_API_KEY" | jq .subject_type puede ser natural | legal | both. Las plantillas vienen
pre-configuradas con severities (approve | review | reject) y scores.
const published = await dj.bhawk.validations.publish(validation.id);// { id, status: 'published', version: 1, ... }curl -X POST "$DJ_BASE/v1/validations/$VALIDATION_ID/publish" \ -H "X-API-Key: $DJ_API_KEY"Esto hace dos cosas atómicamente:
published.code.A partir de acá POST /v1/screenings con validation_code: "default" la usa.
POST /v1/screenings/dry-run evalúa cualquier validation (draft, published,
deprecated) sin persistir y sin cobrar Açaí. Ideal para tests en CI o para
“probar este caso edge antes de publicar”.
const dryRun = await dj.bhawk.screenings.dryRun({ validation_code: 'default', subject_ref: 'user-quickstart-001', subject_type: 'natural', payload: { full_name: 'Camila Rodríguez', birth_date: '1992-04-18', document_type: 'CC', document_number: '1023456789', country_code: 'CO', subject_age: 33, kyc_status: 'approved', amount: 5000000, currency: 'COP', },});
console.log(dryRun.decision, dryRun.findings);curl -X POST "$DJ_BASE/v1/screenings/dry-run" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"validation_id\": \"$VALIDATION_ID\", \"facts\": { \"full_name\": \"Camila Rodríguez\", \"birth_date\": \"1992-04-18\", \"document_type\": \"CC\", \"document_number\": \"1023456789\", \"country_code\": \"CO\", \"subject_age\": 33, \"kyc_status\": \"approved\", \"amount\": 5000000, \"currency\": \"COP\" } }"const dryRun = await fetch(`${DJ_BASE}/v1/screenings/dry-run`, { method: 'POST', headers: { 'X-API-Key': DJ_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ validation_id: validation.id, facts: { full_name: 'Camila Rodríguez', birth_date: '1992-04-18', document_type: 'CC', document_number: '1023456789', country_code: 'CO', subject_age: 33, kyc_status: 'approved', amount: 5000000, currency: 'COP', }, }),}).then(r => r.json());
console.log(dryRun.decision, dryRun.findings);body, _ := json.Marshal(map[string]any{ "validation_id": validationID, "facts": map[string]any{ "full_name": "Camila Rodríguez", "birth_date": "1992-04-18", "document_type": "CC", "document_number": "1023456789", "country_code": "CO", "subject_age": 33, "kyc_status": "approved", "amount": 5000000, "currency": "COP", },})req, _ := http.NewRequest("POST", base+"/v1/screenings/dry-run", bytes.NewReader(body))req.Header.Set("X-API-Key", apiKey)req.Header.Set("Content-Type", "application/json")Respuesta:
{ "decision": "approve", "score": 0, "findings": [], "list_hits": []}Probá con un caso PEP — cambia full_name por el de un PEP conocido (el
screener consulta OpenSanctions/Yente):
{ "decision": "review", "score": 60, "findings": [ { "rule_code": "pep", "node_id": "a1", "decision": "review", "score": 60, "reason": "Subject identificado como PEP — DDR requerida." } ], "list_hits": ["PEP"]}Idéntico body al dry-run pero contra POST /v1/screenings (sin validation_id
— usa la publicada del validation_code que pasés, default "default").
Debita 8 Açaí y persiste la evaluación en bhawk.risk_evaluations.
import { InsufficientAcaiError } from '@bjungle/sdk';
try { const screening = await dj.bhawk.screenings.create({ validation_code: 'default', subject_ref: 'user-quickstart-001', subject_type: 'natural', payload: { full_name: 'Camila Rodríguez', birth_date: '1992-04-18', document_type: 'CC', document_number: '1023456789', country_code: 'CO', subject_age: 33, kyc_status: 'approved', }, });
// screening.decision: 'approve' | 'review' | 'reject'} catch (err) { if (err instanceof InsufficientAcaiError) { // 402 — balance < 8 Açaí. Bloquear + notificar ops. } else { throw err; }}curl -X POST "$DJ_BASE/v1/screenings" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "subject_ref": "user-quickstart-001", "validation_code": "default", "facts": { "full_name": "Camila Rodríguez", "birth_date": "1992-04-18", "document_type": "CC", "document_number": "1023456789", "country_code": "CO", "subject_age": 33, "kyc_status": "approved" } }'const screening = await fetch(`${DJ_BASE}/v1/screenings`, { method: 'POST', headers: { 'X-API-Key': DJ_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ subject_ref: 'user-quickstart-001', validation_code: 'default', facts: { /* ...mismo shape que dry-run */ }, }),}).then(r => r.json());subject_ref es opaco — el ID que tu sistema asigna al usuario. bhawk lo guarda
para correlación pero no comparte foreign keys con bmonkey (ver gotcha #5 de
CLAUDE.md). Eso te deja libre de elegir el shape que quieras.
Decisiones agregadas: reject > review > approve. Si alguna regla dispara
reject, el screening completo es reject.
Cuando la decisión es review o reject, bhawk auto-abre un caso en el
workbench.
// Listar casos abiertosconst page = await dj.bhawk.cases.list({ status: 'open', limit: 50 });console.log(page.items.length, 'casos abiertos');
// Asignar a un analistaawait dj.bhawk.cases.assign(caseId, { assignee: 'compliance@tufintech.co' });
// Resolverawait dj.bhawk.cases.resolve(caseId, { decision: 'approve', reason: 'PEP confirmado pero perfil de bajo riesgo',});
// Escalarawait dj.bhawk.cases.escalate(caseId, { reason: 'monto excede umbral DDR' });# Listar casos abiertoscurl "$DJ_BASE/v1/cases?status=open&limit=50" \ -H "X-API-Key: $DJ_API_KEY" | jq .
# Dashboard KPIscurl "$DJ_BASE/v1/cases/stats" \ -H "X-API-Key: $DJ_API_KEY"
# Asignar a un analistacurl -X POST "$DJ_BASE/v1/cases/$CASE_ID/assign" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d '{"assignee_email":"compliance@tufintech.co"}'
# Resolvercurl -X POST "$DJ_BASE/v1/cases/$CASE_ID/resolve" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d '{"decision":"approve","note":"PEP confirmado pero perfil de bajo riesgo"}'
# Escalar a supervisorcurl -X POST "$DJ_BASE/v1/cases/$CASE_ID/escalate" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d '{"reason":"monto excede umbral DDR"}'
# Exportar ROS (formato SARLAFT)curl "$DJ_BASE/v1/cases/export?from=2026-01-01&to=2026-06-01" \ -H "X-API-Key: $DJ_API_KEY" -o casos.csv// Listarconst { items } = await fetch( `${DJ_BASE}/v1/cases?status=open&limit=50`, { headers: { 'X-API-Key': DJ_API_KEY } },).then(r => r.json());
// Resolverawait fetch(`${DJ_BASE}/v1/cases/${caseId}/resolve`, { method: 'POST', headers: { 'X-API-Key': DJ_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ decision: 'approve', note: 'PEP confirmado pero perfil de bajo riesgo', }),});Endpoints del caso:
| Verbo | Path | Acción |
|---|---|---|
GET | /v1/cases?status= | Listar (cursor pagination) |
GET | /v1/cases/stats | KPIs (count por estado) |
GET | /v1/cases/export?from=&to= | CSV ROS |
GET | /v1/cases/{id} | Detalle + timeline auditable |
POST | /v1/cases/{id}/assign | Asignar a analista |
POST | /v1/cases/{id}/comment | Comentar |
POST | /v1/cases/{id}/resolve | Resolver con `approve |
POST | /v1/cases/{id}/escalate | Escalar a supervisor |
POST | /v1/cases/{id}/reopen | Reabrir resuelto |
El flow default de bmonkey ya incluye un step sarlaft que llama a bhawk vía
S2S internamente. Si querés que tu flow customizado lo haga:
# 1. Clonar el flow default para editarcurl -X POST "$BMONKEY_BASE/v1/flows/$FLOW_ID/clone" -H "X-API-Key: $DJ_API_KEY"
# 2. PUT steps con sarlaft en la posición que quierascurl -X PUT "$BMONKEY_BASE/v1/flows/$NEW_FLOW_ID/steps" \ -H "X-API-Key: $DJ_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "steps": [ { "step_type": "consent", "required": true, "config": {} }, { "step_type": "document_upload", "required": true, "config": {} }, { "step_type": "face_match", "required": true, "config": {} }, { "step_type": "sarlaft", "required": true, "config": {"validation_code":"default"} } ] }'
# 3. Publicarcurl -X POST "$BMONKEY_BASE/v1/flows/$NEW_FLOW_ID/publish" -H "X-API-Key: $DJ_API_KEY"El validation_code por default es "default". Si querés correr distintas
reglas según país, podés tener default, default-mx, default-ar y
referenciar la apropiada al crear la sesión.
Cada regla es un grafo de Condition + Action nodes con edges dirigidas.
Semantics AND: una action dispara cuando todas sus conditions entrantes
son true. Para OR usá actions separadas.
{ "code": "alto-monto-pais-riesgo", "severity": "review", "score": 50, "subject_type": "natural", "definition": { "nodes": [ { "id":"c1", "type":"condition", "predicate":"amount_gte", "value": 10000000 }, { "id":"c2", "type":"condition", "predicate":"country_in", "value": ["VE","KP","IR"] }, { "id":"a1", "type":"action", "decision":"review", "score": 50, "reason":"Monto alto desde país de riesgo — DDR requerida." } ], "edges": [ { "from":"c1", "to":"a1" }, { "from":"c2", "to":"a1" } ] }}Predicados disponibles (parcial):
subject_age_gte, subject_age_lt, country_in, country_not_inon_restrictive_list (codes: OFAC | ONU | PEP | EU_SANCTIONS | FATF)amount_gte, currency_inkyc_status_inEl catálogo completo + schema vive en shared-libs/go-bjungle-bhawk-evaluator.
| Síntoma | Causa típica | Fix |
|---|---|---|
Screening siempre devuelve approve sin hits | OpenSanctions sidecar no configurado en sandbox | Esperable en QA; para prod, contactar para activar |
409 en publish | Otra validation con mismo code ya está publicada | Es el flujo correcto — la auto-deprecada queda en histórico |
400 validation-not-draft al editar regla | Estás editando una validation publicada | Cloná → editás el draft → publish |
402 insufficient-acai en /v1/screenings | Balance < 8 Açaí | Sandbox arranca con 1000 simulados |
| Análisis con dry-run difiere de screening real | OpenSanctions hits dependen del momento (catálogo se actualiza) | Esperado; dry-run es snapshot del momento |
Reglas avanzadas
Concepto completo del motor de reglas, predicados y semantics.
Concepto →API Reference Bhawk
Spec OpenAPI 3.1 completo, interactivo con Scalar.
Abrir →