> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Motor de reglas — condiciones y efectos

> Cuando esto se entregue, un tenant podrá decir en la consola "cuando un cliente pague una factura, dale un punto por cada mil pesos", y ocurrirá — sin un ingeniero de por medio.

> Traducción. Autoritativo: [`fs-loy-0004-rules-engine.md`](/modules/loyalty/features/fs-loy-0004-rules-engine).

## Contexto

Esto es lo que convierte al módulo en un producto y no en una base de datos. El contrato al que
convergió toda la industria es **evento → condiciones → efectos**: llega un evento, se evalúa contra
las reglas activas, y las que calzan emiten efectos. Talon.One construyó una empresa sobre esa
abstracción y Open Loyalty implementa la misma forma, lo que es buena evidencia de que es la
correcta.

La tensión de diseño es expresividad contra predictibilidad. Un lenguaje de reglas lo bastante
potente para ser Turing-completo es un lenguaje con el que un tenant se puede colgar, y sobre el que
los agentes no pueden razonar. La resolución es la misma que se usó para segmentos (ADR-012): un
árbol de condiciones declarativo y versionado, deliberadamente restringido, más un **catálogo
paramétrico de efectos** para que agregar un tipo de efecto sea un cambio de código con su spec,
nunca un insert.

## Alcance *(normativo)*

* `loyalty.rule_campaigns`: contenedor con ventana de vigencia y presupuestos total y por contacto.
* `loyalty.rules`: AST de condiciones versionado (JSON) más una lista ordenada de efectos.
* `loyalty.rule_effect_types`: paramétrico, semillas `is_system` `award_points`, `issue_coupon`,
  `send_webhook`, `trigger_campaign`.
* El matcher: dado un evento registrado y un snapshot del contacto, producir las reglas que calzan.
* El ejecutor del efecto `award_points`, llamando al servicio del libro de FS-LOY-0002.
* Caché de reglas compiladas por tenant, invalidada al publicar.
* `loyalty.rule_executions` para deduplicación y para responder "por qué este member recibió estos
  puntos".
* Dry-run: evaluar un evento contra las reglas activas y devolver los efectos sin aplicarlos.

## Fuera de alcance *(normativo)*

* Efectos distintos de `award_points`. `issue_coupon` llega con FS-LOY-0007, `apply_discount` es F2
  (DEC-H4) y no cuesta nada ahora porque el catálogo ya está abierto.
* La UI de consola para construir reglas — F1b.
* La pertenencia a segmentos como condición: la condición puede referenciar un segmento, pero los
  segmentos son propiedad de `core` y se evalúan allá.
* La ingesta del evento en sí, que es TS-003.

## Comportamiento *(normativo)*

1. Las reglas son **versionadas e inmutables una vez publicadas**. Editar una regla publicada crea
   una versión nueva; la anterior permanece para auditar qué aplicó realmente una ejecución.
2. Publicar invalida la caché de reglas compiladas del tenant. Una regla rige para los eventos que
   llegan después de la publicación, nunca retroactivamente.
3. Las condiciones son un árbol declarativo restringido: atributos tipados de perfil, propiedades
   del evento, agregados de eventos sobre una ventana, pertenencia a segmento y a nivel, y límites
   de frecuencia, combinados con AND/OR/NOT. PROHIBIDO: expresiones arbitrarias, código provisto por
   el usuario, bucles no acotados.
4. Los efectos se **deduplican por (evento, regla)**. Reprocesar el mismo evento nunca debe acreditar
   puntos dos veces. Esto lo garantiza un constraint único sobre `rule_executions`, no una
   convención.
5. El presupuesto de una campaña se verifica y decrementa **dentro de la transacción del efecto**.
   Pasarse del presupuesto es imposible, no improbable.
6. Los efectos se ejecutan en el orden declarado. Un efecto que falla aborta los efectos restantes de
   esa regla y registra la falla; los efectos de otras reglas no se ven afectados.
7. Agregar una fila a `rule_effect_types` no agrega comportamiento. La API rechaza un código de
   efecto no soportado con `UNSUPPORTED_CODE` al publicar la regla, no al ejecutarla.
8. La evaluación ocurre en el procesador, fuera del camino caliente. El endpoint de ingesta ya
   respondió 202.

## Datos *(normativo)*

| Tabla                       | Invariantes clave                                                                                                                       |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.rule_campaigns`    | `program_id`; `starts_at`/`ends_at`; `budget_total`, `budget_per_contact` (nullable = ilimitado); `is_active`                           |
| `loyalty.rules`             | FK campaña; `version` entero, inmutable una vez publicada; `conditions` JSONB; `effects` JSONB ordenado; `published_at`                 |
| `loyalty.rule_effect_types` | paramétrica; semillas `is_system` según alcance; no extensible por tenant                                                               |
| `loyalty.rule_executions`   | único (`rule_id`, `rule_version`, `source_event_id`); registra los efectos aplicados y su resultado; append-only; candidata a partición |

## API *(normativo)*

| Endpoint                               | Clase      | Permiso                                    | Presupuesto |
| -------------------------------------- | ---------- | ------------------------------------------ | ----------- |
| `GET/POST/PATCH /v1/loyalty/campaigns` | Management | `loyalty.campaigns.{read\|create\|update}` | p95 \<1 s   |
| `GET/POST /v1/loyalty/rules`           | Management | `loyalty.rules.{read\|create}`             | p95 \<1 s   |
| `POST /v1/loyalty/rules/{id}/publish`  | Management | `loyalty.rules.publish`                    | p95 \<1 s   |
| `POST /v1/loyalty/rules/simulate`      | Management | `loyalty.rules.simulate`                   | p95 \<1 s   |

`simulate` es el dry-run. Es un endpoint de Management a propósito: es una herramienta de diseño, no
un camino de runtime, y jamás debe poder escribir.

## Eventos *(normativo)*

Emite `loyalty.points.earned` a través del servicio del libro cuando se ejecuta `award_points`.
Consume `core.event.tracked` del pipeline de ingesta.

## Criterios de aceptación *(normativo)*

1. Una regla publicada "1 punto por cada 1000 unidades monetarias en `invoice_paid`" aplicada a un
   evento de 45 990 CLP acredita exactamente 45 puntos.
2. Reprocesar el mismo evento 100 veces produce exactamente una fila en el libro y una en
   `rule_executions` — el test de tormenta de replays.
3. Editar una regla publicada crea la versión 2; una ejecución registrada bajo la versión 1 sigue
   reportando las condiciones que efectivamente se aplicaron.
4. Una campaña con `budget_total = 1000` no puede excederlo con 50 eventos calificantes concurrentes.
5. Publicar invalida la caché: un evento que llega inmediatamente después calza con la regla nueva;
   uno que llegó antes, no.
6. `simulate` devuelve los efectos y no escribe nada — verificado por el conteo de filas del libro
   antes y después.
7. **Negativo:** una regla que referencia un código de efecto sin implementación se rechaza al
   publicar con `UNSUPPORTED_CODE`.

## Ejecución

Arquetipo pipeline asíncrono — esta es la feature que TS-004 existe para demostrar. El matcher y el
ejecutor viven en `backend/workers`; los endpoints de gestión en `backend/api`.

## Preguntas abiertas

Ninguna pendiente. Toda pregunta que llevaba este spec quedó respondida en el registro consolidado
([`../../../../design/open-questions-v1.md`](/design/open-questions-v1), v1.1) y se
incorporó a las secciones normativas de arriba.

## Changelog

| Versión | Fecha      | Cambio                                                                             | Por qué                                    | Autor                  |
| ------- | ---------- | ---------------------------------------------------------------------------------- | ------------------------------------------ | ---------------------- |
| 0.2.0   | 2026-08-17 | Preguntas abiertas resueltas (OQ-LOY-\*) e incorporadas a las secciones normativas | El owner respondió el registro consolidado | daniel + claude-opus-5 |
| 0.1.0   | 2026-08-17 | Borrador inicial                                                                   | —                                          | daniel + claude-opus-5 |

## Registro de entrega

*Aún no implementado.*
