ADR-012 · SARLAFT continuo parametrizable por tenant
| Campo | Valor |
|---|---|
| Status | Accepted (jguerrero 2026-06-05) |
| Date | 2026-06-05 |
| Authors | tech-lead agent + jguerrero |
| Supersedes | — |
| Superseded by | — |
Context
Sección titulada «Context»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-apitiene screening sincrónico (gap #28 ✅) via S2SPOST /v1/internal/screeningsconX-Internal-Token.bhawk.casestabla existe conassigneeindexada (cases_assignee_open_idx).bmonkey-workercorre 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_policiesni 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.
Decision
Sección titulada «Decision»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-policyX-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).
3. Cron worker en bmonkey-worker
Sección titulada «3. Cron worker en bmonkey-worker»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→ 24hweekly→ 7dmonthly→ 30ddisabled→ 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.
4. Dedupe — skip si nada cambió
Sección titulada «4. Dedupe — skip si nada cambió»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%.
5. NATS event + multi-canal notifier
Sección titulada «5. NATS event + multi-canal notifier»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:
5.a · Email (SES)
Sección titulada «5.a · Email (SES)»- Destino:
screening_policies.notification_emaildel tenant - Template:
sarlaft-alert.es.htmlcon subject + rule details + link a dashboard - Si email vacío → skip email, log warning (canal opcional)
5.b · In-app push (tenant-portal)
Sección titulada «5.b · In-app push (tenant-portal)»- Inserta row en
bhawk.cases(tabla existente) constatus='open',assignee=NULL,prioritycalculado del outcome - tenant-portal vía polling cada 30s (o websocket cuando se implemente) detecta nuevos cases y muestra badge
5.c · Dashboard queue
Sección titulada «5.c · Dashboard queue»- Página nueva
/compliance/sarlaften 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-defaultscon tenant cashpaya context → instancia el catálogo (OFAC + UN + EU + FATF + PEP, threshold 0.85) y publica comocashpaya_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).
7. Performance + reliability
Sección titulada «7. Performance + reliability»- 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_atNO 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 bucketsarlaft_cron_leadercon TTL 90s, refresh cada 30s.
Consequences
Sección titulada «Consequences»Positivas
Sección titulada «Positivas»- 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_logcontenant_id + subject_id + outcome + matched_rules. Hay rastro completo para audit SFC.
Negativas / trade-offs
Sección titulada «Negativas / trade-offs»- 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.
Neutrales
Sección titulada «Neutrales»- Validations versionadas — switch inmediato (OQ5 resuelta): una
validation_codepublished 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). Elaudit_logregistravalidation_version_usedpor cada screening individual, lo que mantiene la trazabilidad aunque un ciclo mezcle v1 y v2. screening_policiesUNIQUE por tenant: rompe el patrón si en el futuro queremos políticas por segmento. Aceptable, requiere migration cuando llegue ese caso.
Alternatives considered
Sección titulada «Alternatives considered»| Alternativa | Por qué se descartó |
|---|---|
| Screening solo on-demand (sin continuo) | No cumple SFC. Gap crítico. Esto NO es opcional. |
| Polling diario hardcoded en bmonkey-worker | No 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 schedule | Bonito 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 subject | Acopla 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. |
Implementation notes
Sección titulada «Implementation notes»Files a tocar (Wave D1-D5):
bmonkey/backend/db-bmonkey/migrations/0031_screening_policies.up.sql— nueva tabla + RLSbmonkey/backend/go-bmonkey-api/internal/domain/screening_policy.go— nuevobmonkey/backend/go-bmonkey-api/internal/repo/screening_policy_repo.go— nuevobmonkey/backend/go-bmonkey-api/internal/service/screening_policy_service.go— nuevobmonkey/backend/go-bmonkey-api/internal/http/screening_policy_handler.go— endpoints PATCH/GETbmonkey/backend/go-bmonkey-worker/internal/worker/sarlaft_cron.go— nuevo cron loopbmonkey/backend/go-bmonkey-worker/internal/handlers/sarlaft_alert.go— dispatch multi-canalbhawk/backend/go-bhawk-api/internal/http/internal_handler.go— extender/v1/internal/screeningspara soportar el flagre_screen=true(audit context)bhawk/backend/go-bhawk-api/internal/repo/cases_repo.go— métodoInsertAlertCase(tenant, subject, outcome, rules)platform/frontend/tenant-portal/src/routes/_authed.compliance.sarlaft.tsx— nueva páginaplatform/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 encases+ emit aevents_outboxdebe ser atómico vía un único SECURITY DEFINER function. - Audit log append-only: cada decision SARLAFT entra a
audit_logglobal (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_atse 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_emailes 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_atno avanza (bug): worker quedaría en loop infinito re-procesando el mismo tenant. Mitigación: setearnext_run_aten 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).
Compliance / regulatory considerations
Sección titulada «Compliance / regulatory considerations»- 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
weeklyexcede 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 (
consentstep) 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+casesno se purgan por CLAUDE.md gotcha #7. Esto cumple retención de 5 años SARLAFT.
Open questions
Sección titulada «Open questions»Resoluciones jguerrero 2026-06-05 — todas cerradas para Accepted.
| # | Pregunta | Resolución |
|---|---|---|
| 1 | Frecuencia default tenant nuevo | Weekly. 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). |
| 2 | Webhook genérico al SIEM del tenant | Backlog 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. |
| 3 | Quién paga el screening continuo | Pass-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. |
| 4 | Re-screen post-onboarding outcome=reject afecta kyc_status | Flag 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. |
| 5 | validation_code republicado durante ciclo activo | Switch 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. |
| 6 | Bootstrap policy default al crear tenant | Sí, 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. |
Cross-references
Sección titulada «Cross-references»- Gap analysis gaps #28-#38 (SARLAFT/Compliance) + decisión #6 y #7:
integracion-cashpaya-gap-analysis.md - Backlog Wave D1-D5:
cashpaya-integration-backlog.md - ADR-009 Cashpaya como primer cliente
- bhawk como compliance engine — motor de reglas que este ADR consume
- ADR-006 Flow composition — el
sarlaftstep del onboarding (screening inicial) - ADR-004 API key hardening — scopes para
screening-policyendpoints