Ir al contenido

ADR-012 · SARLAFT continuo parametrizable por tenant

CampoValor
StatusAccepted (jguerrero 2026-06-05)
Date2026-06-05
Authorstech-lead agent + jguerrero
Supersedes
Superseded by

El gap #33 del análisis es explícito y crítico:

Screening continuo periódico (post-onboarding): NO existe re-screen cron. Compliance colombiana SFC lo exige. GAP CRÍTICO.

El SARLAFT (Sistema de Administración del Riesgo de Lavado de Activos y Financiación del Terrorismo) en Colombia, Circular Básica Jurídica SFC 028 de 2014 y sus modificaciones, requiere que las entidades vigiladas monitoreen continuamente sus clientes contra listas restrictivas actualizadas. El screening de onboarding (gap #28-#31 ya implementado) cubre el inicial pero no el continuo. Si un subject aprueba el KYC en enero y entra a una lista OFAC en marzo, hoy no nos enteramos hasta que el subject vuelva a triggear un re-screen manual.

Decisiones jguerrero relevantes (2026-06-05):

  • #6: SARLAFT continuo semanal con dedupe + parametrizable por tenant (frecuencia configurable). Balance compliance/costo; tenant ajusta a su risk-appetite.
  • #7: Notificaciones SARLAFT flagged → email compliance officer + in-app push + dashboard queue. Cobertura multi-canal.
  • #4: Reusabilidad obligatoria — cualquier tenant con riesgo similar (otra fintech, EPS, telcos) usa el mismo motor sin código custom.

Estado del código actual:

  • bhawk-api tiene screening sincrónico (gap #28 ✅) via S2S POST /v1/internal/screenings con X-Internal-Token.
  • bhawk.cases tabla existe con assignee indexada (cases_assignee_open_idx).
  • bmonkey-worker corre como consumer NATS JetStream pero no tiene un cron loop sobre subjects.
  • Última migration bhawk: 0006_dba_review_indexes.
  • Última migration bmonkey: 0030_oidc_client_register_fix.
  • No existe tabla screening_policies ni cron worker de re-screen.

Este ADR fija el modelo de datos + flow operacional + dispatch de alertas. Es el último P0 critical del backlog que aún no tiene diseño.

1. Nueva tabla bmonkey.screening_policies (tenant-scoped, RLS)

Sección titulada «1. Nueva tabla bmonkey.screening_policies (tenant-scoped, RLS)»

Vive en bmonkey porque el subject es la entidad bmonkey — bhawk es solo el motor de reglas. La política dice cuándo y a quién re-screenear; bhawk responde con qué decisión.

CREATE TYPE screening_frequency AS ENUM (
'daily', 'weekly', 'monthly', 'disabled'
);
CREATE TABLE bmonkey.screening_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL, -- RLS scope
frequency screening_frequency NOT NULL DEFAULT 'weekly',
active boolean NOT NULL DEFAULT true,
validation_code text NOT NULL, -- bhawk validation a ejecutar
notification_email text, -- compliance officer destino
last_run_at timestamptz,
next_run_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id) -- una política por tenant (MVP)
);
ALTER TABLE bmonkey.screening_policies ENABLE ROW LEVEL SECURITY;
ALTER TABLE bmonkey.screening_policies FORCE ROW LEVEL SECURITY;
CREATE POLICY screening_policies_tenant
ON bmonkey.screening_policies
USING (tenant_id = current_setting('app.current_tenant', true)::uuid);
CREATE INDEX idx_screening_policies_next_run
ON bmonkey.screening_policies(next_run_at)
WHERE active = true AND frequency <> 'disabled';

MVP: una política por tenant. Si en el futuro un tenant necesita políticas distintas por segmento (e.g. high-risk weekly, low-risk monthly), se agrega columna subject_filter jsonb. Sin sobreingeniería hoy.

2. Endpoint PATCH /v1/tenants/me/screening-policy

Sección titulada «2. Endpoint PATCH /v1/tenants/me/screening-policy»
PATCH /v1/tenants/me/screening-policy
X-API-Key: dj_live_<tenant>
Content-Type: application/json
{
"frequency": "weekly",
"active": true,
"validation_code": "cashpaya_sarlaft_v1",
"notification_email": "compliance@cashpaya.co"
}

Scopes requeridos: bmonkey:settings:write + bhawk:screenings:read (para validar que validation_code existe published).

Response 200: política activa + next_run_at calculado del frequency.

Hay también GET /v1/tenants/me/screening-policy (read).

Nuevo worker module internal/worker/sarlaft_cron.go que corre como leader-elected loop (single instance — NATS JetStream KV lock o similar). Cada minuto:

1. SELECT id, tenant_id, validation_code, frequency
FROM bmonkey.screening_policies
WHERE active = true AND frequency <> 'disabled'
AND next_run_at <= now()
ORDER BY next_run_at ASC LIMIT 50;
2. For each policy:
a. List subjects WHERE kyc_status = 'approved' AND tenant_id = policy.tenant_id
b. For each subject:
- dedupe check (ver §4)
- if not deduped:
POST bhawk /v1/internal/screenings (S2S) with subject + validation_code
process response (approve/review/reject)
c. UPDATE policy SET last_run_at = now(), next_run_at = now() + interval(frequency)

interval(frequency):

  • daily → 24h
  • weekly → 7d
  • monthly → 30d
  • disabled → no se procesa (filter en query)

Si una corrida toma >1 hora porque hay 100k subjects en un tenant grande, el siguiente tick del cron espera (no spawnea paralelo del mismo tenant). MVP: paralelismo por tenant, no por subject dentro del tenant.

Tres condiciones para que se ejecute un re-screen del subject:

re_screen_needed(subject, policy) =
subject.last_screened_at < policy.last_run_at -- nunca screened en este ciclo
OR
last_lists_updated_at > subject.last_screened_at -- listas actualizadas desde último screen
OR
subject.profile_changed_at > subject.last_screened_at -- cambió nombre, DOB, etc.

last_lists_updated_at lo expone el servicio OpenSanctions (o sidecar Python) como un campo GET /healthz o /lists/last-updated. bhawk lo cachea en memoria con TTL 1h.

profile_changed_at se actualiza cuando un admin edita el subject (corrige OCR, etc.). Trigger postgres ya existente.

Sin dedupe, screening semanal de un tenant con 10k subjects sería ~10k × 4/mes = 40k screenings/mes. Con dedupe (listas se actualizan ~1/sem en OpenSanctions), bajamos a ~10-15k/mes. Costo Açaí reducido ~70%.

Cuando bhawk devuelve outcome = review | reject para un subject ya verified previamente (es decir, nuevo match post-onboarding), bmonkey-worker emite:

{
"subject": "bjungle.sarlaft.alert.created",
"data": {
"tenant_id": "uuid",
"subject_id": "uuid",
"subject_display_name": "...",
"screening_outcome": "review|reject",
"matched_rules": ["OFAC_2024_001", "PEP_CO_2025_42"],
"policy_id": "uuid",
"occurred_at": "2026-06-05T12:34:56Z"
}
}

Subscriber: nuevo handler en bmonkey-worker/internal/handlers/sarlaft_alert.go que dispatcha a 3 canales:

  • Destino: screening_policies.notification_email del tenant
  • Template: sarlaft-alert.es.html con subject + rule details + link a dashboard
  • Si email vacío → skip email, log warning (canal opcional)
  • Inserta row en bhawk.cases (tabla existente) con status='open', assignee=NULL, priority calculado del outcome
  • tenant-portal vía polling cada 30s (o websocket cuando se implemente) detecta nuevos cases y muestra badge
  • Página nueva /compliance/sarlaft en tenant-portal (platform/frontend/tenant-portal/src/routes/_authed.compliance.sarlaft.tsx)
  • Lista cases con filtros: status, priority, assignee
  • Botones: “Asignar a mí”, “Marcar revisado”, “Escalar”
  • Usa endpoints existentes de bhawk cases (gap #36 ✅)

6. Catálogo de validations SARLAFT por tenant

Sección titulada «6. Catálogo de validations SARLAFT por tenant»

Cada tenant elige una validation_code published en bhawk. Hoy seed-defaults provee sarlaft_default_v1 (gap #32). Para cashpaya:

  • Wave A2 del backlog: POST /v1/rules/seed-defaults con tenant cashpaya context → instancia el catálogo (OFAC + UN + EU + FATF + PEP, threshold 0.85) y publica como cashpaya_sarlaft_v1.

El catálogo de listas restrictivas vive en shared-libs/go-bjungle-bhawk-templates/catalog.go. Si OpenSanctions agrega una lista nueva (ej. EU_BELARUS_2025), hay que actualizar el catálogo y el dropdown del UI (gotcha del CLAUDE.md).

  • Throttle a bhawk: bmonkey-worker no spammea bhawk con 1k requests/seg. Rate limit interno: max 10 RPS por tenant. Si tenant tiene 100k subjects y rate=10 RPS → toma ~2.7 horas. Aceptable porque el cron es weekly. Para tenants grandes futuros, considerar bulk screening API (backlog).
  • Resumability: si el worker crashea a mitad de un tenant, last_run_at NO se actualiza. Al reiniciar, el cron lo reagenda inmediatamente (next_run_at <= now). El dedupe del §4 evita re-screenear los que ya completaron en este ciclo.
  • Leader election: durante un deploy con N réplicas del worker, solo una corre el loop SARLAFT. Si la que tenía el lock muere, otra toma el lock en <1 min. Implementación: NATS JetStream KV bucket sarlaft_cron_leader con TTL 90s, refresh cada 30s.
  • Cumple SARLAFT SFC: cierre del gap #33 crítico. Sin esto cashpaya no podría pasar audit SFC.
  • Parametrizable por tenant: cada tenant ajusta frequency según su perfil de riesgo (fintech high-risk = daily, app de delivery low-risk = monthly). No one-size-fits-all.
  • Dedupe agresivo reduce costo Açaí ~70%: muy importante para tenants con miles de subjects. Sin dedupe el screening continuo es ruinoso.
  • Multi-canal de alertas: compliance officer recibe email (no se le escapa), in-app le muestra badge cuando entra al dashboard, queue permite review batch. Ninguna alerta se pierde.
  • Reusable cross-tenant: el cron worker no menciona cashpaya por ningún lado. Itera políticas en orden de next_run_at. Tenant 2, 3, … heredan sin esfuerzo.
  • Auditable: cada re-screen deja entrada en audit_log con tenant_id + subject_id + outcome + matched_rules. Hay rastro completo para audit SFC.
  • Costo Açaí recurrente: aun con dedupe del 70%, un tenant con 10k subjects en weekly paga ~3k screenings/mes × screening_cost. Si screening cuesta 40 Açaí → 120k Açaí/mes ≈ USD 1800/mes solo en compliance continuo. Decisión jguerrero pendiente (Open question #5 de ADR-009): ¿plan separado “compliance continuo” o sale del balance normal?
  • Worker single-leader = SPOF: si el leader muere y el lock tarda 90s en liberarse, no se procesan tenants en esa ventana. Aceptable porque el cron es weekly (90s de lag es 0.015% del periodo). Si futuro se vuelve crítico, particionar por tenant_id hash.
  • Throttle 10 RPS por tenant: tenants con 100k+ subjects ven que un ciclo completo toma >24h. Aceptable hoy (cashpaya es 50 users) pero hay que documentar limit para tenants enterprise.
  • Email es opcional: si el tenant no setea notification_email, pierden ese canal. UI debe enforced que se setee al menos uno (email o webhook).
  • No hay webhook al tenant: el dispatch es solo email + in-app + dashboard interno. Si el tenant tiene su propio SIEM o slack para alertas, no hay integración. Backlog: webhook genérico para alerts multi-tenant.
  • Validations versionadas — switch inmediato (OQ5 resuelta): una validation_code published deprecará automáticamente la anterior (patrón bhawk existente). Si v2 se publica a mitad de un ciclo SARLAFT activo, los subjects restantes del ciclo usan v2 desde la próxima resolución del cron (no se snapshot-ea v1 por ciclo). El audit_log registra validation_version_used por cada screening individual, lo que mantiene la trazabilidad aunque un ciclo mezcle v1 y v2.
  • screening_policies UNIQUE por tenant: rompe el patrón si en el futuro queremos políticas por segmento. Aceptable, requiere migration cuando llegue ese caso.
AlternativaPor qué se descartó
Screening solo on-demand (sin continuo)No cumple SFC. Gap crítico. Esto NO es opcional.
Polling diario hardcoded en bmonkey-workerNo parametrizable, ignora low-risk tenants (delivery apps no necesitan diario). Costo Açaí incontrolable. Decisión jguerrero #6 fue explícita: parametrizable.
Event-driven only (subscribe a updates de OpenSanctions y re-screen reactivo)Más complejo: requiere que OpenSanctions emita eventos por cambio + manejar replay. Beneficio marginal sobre cron + dedupe — la dedupe del §4 ya cubre el “solo si listas cambiaron”.
Worker por tenant (un cron loop por tenant aislado)N tenants = N workers. Hot tenant satura recursos, cold tenant queda ocioso. Single loop con throttle por tenant es más eficiente.
Lambda + EventBridge scheduleBonito pero requiere meter AWS Lambda al stack (hoy todo es ECS). Operational complexity nueva. Postponer hasta que tengamos otro use case Lambda.
Re-screen on subject update solo (cuando el subject cambia algo)No cubre el caso “subject estático pero lista cambió”. El SARLAFT requiere monitor de listas, no de subjects.
Tabla subject_screening_schedule (una row por subject)N rows = N subjects. Para 100k subjects, 100k rows de schedule. La cron query se vuelve costosa. Una policy por tenant + dedupe por subject es más eficiente.
Trigger postgres en update de subjectAcopla schema bmonkey a worker. Postgres triggers son frágiles para lógica de negocio. Mantener trigger como hint (profile_changed_at) pero no como dispatcher.

Files a tocar (Wave D1-D5):

  • bmonkey/backend/db-bmonkey/migrations/0031_screening_policies.up.sql — nueva tabla + RLS
  • bmonkey/backend/go-bmonkey-api/internal/domain/screening_policy.go — nuevo
  • bmonkey/backend/go-bmonkey-api/internal/repo/screening_policy_repo.go — nuevo
  • bmonkey/backend/go-bmonkey-api/internal/service/screening_policy_service.go — nuevo
  • bmonkey/backend/go-bmonkey-api/internal/http/screening_policy_handler.go — endpoints PATCH/GET
  • bmonkey/backend/go-bmonkey-worker/internal/worker/sarlaft_cron.go — nuevo cron loop
  • bmonkey/backend/go-bmonkey-worker/internal/handlers/sarlaft_alert.go — dispatch multi-canal
  • bhawk/backend/go-bhawk-api/internal/http/internal_handler.go — extender /v1/internal/screenings para soportar el flag re_screen=true (audit context)
  • bhawk/backend/go-bhawk-api/internal/repo/cases_repo.go — método InsertAlertCase(tenant, subject, outcome, rules)
  • platform/frontend/tenant-portal/src/routes/_authed.compliance.sarlaft.tsx — nueva página
  • platform/frontend/tenant-portal/src/routes/_authed.compliance.policy.tsx — UI para PATCH policy

Patrones a respetar:

  • RLS deny_all + tenant policy sobre screening_policies.
  • Tx + tenant en cada handler PATCH/GET.
  • Worker connect como superuser bypass RLS — patrón ya canónico en bseal-worker. Itera todos los tenants legítimamente.
  • Outbox para NATS event: el INSERT en audit_log + INSERT en cases + emit a events_outbox debe ser atómico vía un único SECURITY DEFINER function.
  • Audit log append-only: cada decision SARLAFT entra a audit_log global (patrón canónico CLAUDE.md gotcha #7).

Tests obligatorios:

  • Happy path weekly: tenant con 3 subjects, policy weekly, primera corrida los procesa los 3, segunda corrida los dedupe (si listas no cambiaron y subjects estáticos).
  • Lista cambió → re-screen forzado: mock last_lists_updated_at > subject.last_screened_at → se re-screena.
  • Subject perfil cambiado → re-screen forzado: admin corrige nombre → profile_changed_at se actualiza → next cron lo re-screena.
  • Outcome=review con subject ya approved → NATS alert emitido + case creado + email enviado.
  • Outcome=reject post-onboarding: subject queda en kyc_status = manual_review (no auto-rollback a rejected, requiere decisión humana).
  • Frequency=disabled: el tenant queda excluido del loop.
  • Tenant inactive: skippado en el query del cron.
  • Leader election: 2 réplicas del worker → solo una corre el loop. Matar la leader, verificar que la otra toma el lock <90s.
  • Throttle 10 RPS: tenant con 1000 subjects → ciclo >100s.

Riesgos a vigilar:

  • OpenSanctions side: si el sidecar Python falla, bhawk devuelve error → bmonkey-worker reagenda con backoff (1h). Alert CloudWatch si tasa de error > 10%.
  • Email bounce: si notification_email es inválido y SES bounce, el tenant nunca se entera. Mitigación: validación al PATCH + dashboard muestra “última alerta enviada hace X días” para que sea evidente.
  • next_run_at no avanza (bug): worker quedaría en loop infinito re-procesando el mismo tenant. Mitigación: setear next_run_at en UPDATE antes del procesamiento, no después. Si el procesamiento crashea, el dedupe cubre la siguiente vuelta.
  • Case explosion: si una lista entra con miles de matches por error (bug OpenSanctions), cashpaya recibiría miles de cases. Mitigación: throttle de cases por tenant por hora (max 100/hr, exceso a una “summary case” agregada).
  • SFC Circular Básica Jurídica 028 de 2014 + 055 de 2021: monitoreo continuo es requisito explícito. Frecuencia mínima recomendada SFC: trimestral. Nuestro default weekly excede el mínimo.
  • Ley 1581 (HABEAS DATA): el screening continuo procesa PII del subject (nombre, DOB) periódicamente. La autorización de tratamiento obtenida en onboarding (consent step) debe cubrir esto explícitamente. Revisar el texto del consent template con counsel.
  • Listas restrictivas obligatorias CO: OFAC, ONU, EU Sanctions, FATF, PEP. La SFC también referencia listas locales (UIAF, lista ONUDI). Confirmar coverage del catálogo bhawk con counsel pre-prod.
  • UIAF Reporte de Operaciones Sospechosas (ROS): si un re-screen detecta sanctions_match en un subject que ya operó, hay ventana regulatoria para reportar (24-48h). Hoy ROS es manual cashpaya. La alert in-app del §5 facilita pero no automatiza.
  • Retención de evidencia: audit_log + cases no se purgan por CLAUDE.md gotcha #7. Esto cumple retención de 5 años SARLAFT.

Resoluciones jguerrero 2026-06-05 — todas cerradas para Accepted.

#PreguntaResolución
1Frecuencia default tenant nuevoWeekly. Default seguro que excede el mínimo SFC (trimestral). Compliance default-safe incluso si el tenant nunca ajusta. UI guía al tenant a calibrar según su risk-appetite (fintech weekly/daily, app low-risk monthly).
2Webhook genérico al SIEM del tenantBacklog Wave E post-cashpaya. MVP sin webhook — email + in-app + dashboard son suficientes para cashpaya. Webhook entra como feature enterprise reusable cuando el primer tenant lo requiera.
3Quién paga el screening continuoPass-through Açaí. Cada screening continuo debita Açaí del balance del tenant igual que el inicial. Tenant controla costo ajustando frequency. Coherente con resto del modelo de pricing por consumo. Plan tiers (ADR-010) pueden cubrir el rango low/mid con bono incluido en el futuro.
4Re-screen post-onboarding outcome=reject afecta kyc_statusFlag requires_review, status estable. kyc_status sigue approved. Se setea requires_review = true + crea case en bhawk.cases. Cashpaya consulta el flag para bloquear operaciones nuevas hasta resolución humana. NO invalida operaciones históricas. Evita fricción severa en falsos positivos OFAC sobre clientes legítimos.
5validation_code republicado durante ciclo activoSwitch inmediato a v2. Cada subject del ciclo usa la versión published al momento de su screening individual. No snapshot por ciclo — el cron resuelve validation_code cada vez que toca a un subject. Auditoría registra validation_version_used por cada screening en audit_log (mitiga la “confusión” de mezclar versiones en un ciclo — queda trazable). Beneficio: si v2 corrige un bug crítico, los subjects restantes ya lo aprovechan sin esperar al próximo ciclo.
6Bootstrap policy default al crear tenantSí, default weekly + sarlaft_default_v1 activo. platform.tenant_create inserta una policy default. Tenant nunca queda sin compliance continuo (default-safe). Puede ajustar PATCH /v1/tenants/me/screening-policy luego — frequency, validation_code, notification_email. Si setea frequency=disabled el cron lo excluye.