> ## 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.

# Rules Engine — Conditions and Effects

> After this ships, a tenant can say "when a customer pays an invoice, give them one point per thousand pesos" in the console, and it happens — without an engineer.

## Context

This is what makes the module a product rather than a database. The contract the whole industry
converged on is **event → conditions → effects**: an event arrives, it is matched against the
active rules, and the matching rules emit effects. Talon.One built a company on that abstraction
and Open Loyalty implements the same shape, which is good evidence that it is the right one.

The design tension is expressiveness against predictability. A rules language powerful enough to
be Turing-complete is a language a tenant can hang themselves with, and one that agents cannot
reason about. The resolution is the same one used for segments (ADR-012): a declarative, versioned
condition tree that is deliberately restricted, plus a **parametric effect catalog** so adding an
effect type is a code change with a spec, never a row insert.

## Scope *(normative)*

* `loyalty.rule_campaigns`: container with an active window, total and per-contact budgets.
* `loyalty.rules`: versioned condition AST (JSON) plus an ordered effect list.
* `loyalty.rule_effect_types`: parametric, `is_system` seeds `award_points`, `issue_coupon`,
  `send_webhook`, `trigger_campaign`.
* The matcher: given a tracked event and a contact snapshot, produce the matching rules.
* The effect executor for `award_points`, calling the ledger service from FS-LOY-0002.
* Per-tenant compiled-rules cache, invalidated on publish.
* `loyalty.rule_executions` for dedupe and for answering "why did this member get these points".
* Dry-run: evaluate an event against active rules and return the effects without applying them.

## Non-scope *(normative)*

* Effects other than `award_points`. `issue_coupon` arrives with FS-LOY-0007, `apply_discount` is
  F2 (DEC-H4) and costs nothing now because the catalog is already open.
* The console UI for building rules — F1b.
* Segment membership as a condition: the condition can reference a segment, but segments are owned
  by `core` and evaluated there.
* Ingestion of the event itself, which is TS-003.

## Behaviour *(normative)*

1. Rules are **versioned and immutable once published**. Editing a published rule creates a new
   version; the old version stays for auditing what an execution actually applied.
2. Publishing invalidates the tenant's compiled-rules cache. A rule takes effect for events
   arriving after publication, never retroactively.
3. Conditions are a restricted declarative tree: typed profile attributes, event properties, event
   aggregates over a window, segment and tier membership, and frequency limits, combined with
   AND/OR/NOT. FORBIDDEN: arbitrary expressions, user-supplied code, unbounded loops.
4. Effects are **deduped per (event, rule)**. Replaying the same event must never award points
   twice. This is enforced by a unique constraint on `rule_executions`, not by convention.
5. A campaign's budget is checked and decremented **inside the effect transaction**. Overrunning a
   budget is impossible, not unlikely.
6. Effects execute in declared order. An effect that fails aborts the remaining effects of that
   rule and records the failure; effects of other rules are unaffected.
7. Adding a row to `rule_effect_types` does not add behaviour. The API rejects an unsupported
   effect code with `UNSUPPORTED_CODE` at rule-publish time, not at execution time.
8. Evaluation happens in the processor, off the hot path. The ingestion endpoint has already
   answered 202.

## Data *(normative)*

| Table                       | Key invariants                                                                                                                     |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.rule_campaigns`    | `program_id`; `starts_at`/`ends_at`; `budget_total`, `budget_per_contact` (nullable = unlimited); `is_active`                      |
| `loyalty.rules`             | FK campaign; `version` integer, immutable once published; `conditions` JSONB; `effects` JSONB ordered; `published_at`              |
| `loyalty.rule_effect_types` | parametric; `is_system` seeds as scoped; not tenant-extensible                                                                     |
| `loyalty.rule_executions`   | unique (`rule_id`, `rule_version`, `source_event_id`); records effects applied and their outcome; append-only; partition candidate |

## API *(normative)*

| Endpoint                               | Class      | Permission                                 | Budget    |
| -------------------------------------- | ---------- | ------------------------------------------ | --------- |
| `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` is the dry-run. It is a management endpoint on purpose: it is a design tool, not a
runtime path, and it must never be able to write.

## Events *(normative)*

Emits `loyalty.points.earned` through the ledger service when `award_points` executes. Consumes
`core.event.tracked` from the ingestion pipeline.

## Acceptance criteria *(normative)*

1. A published rule "1 point per 1000 currency units on `invoice_paid`" applied to a
   45 990 CLP event awards exactly 45 points.
2. Replaying the same event 100 times produces exactly one ledger row and one `rule_executions`
   row — the replay-storm test.
3. Editing a published rule creates version 2; an execution recorded under version 1 still reports
   the conditions that were actually applied.
4. A campaign with `budget_total = 1000` cannot exceed it under 50 concurrent qualifying events.
5. Publishing invalidates the cache: an event arriving immediately after publication matches the
   new rule; an event that arrived before does not.
6. `simulate` returns the effects and writes nothing — verified by a ledger row count before and
   after.
7. **Negative:** a rule referencing an effect code with no implementation is rejected at publish
   time with `UNSUPPORTED_CODE`.

## Execution

Asynchronous event pipeline archetype — this is the feature TS-004 exists to prove. The matcher
and executor live in `backend/workers`; the management endpoints in `backend/api`.

## Open questions

None outstanding. Every question this spec carried was answered in the consolidated register
([`../../../design/open-questions-v1.md`](/design/open-questions-v1), v1.1) and folded
into the normative sections above.

## Changelog

| Version | Date       | Change                                                                     | Why                                      | Author                 |
| ------- | ---------- | -------------------------------------------------------------------------- | ---------------------------------------- | ---------------------- |
| 0.2.0   | 2026-08-17 | Open questions resolved (OQ-LOY-\*) and folded into the normative sections | Owner answered the consolidated register | daniel + claude-opus-5 |
| 0.1.0   | 2026-08-17 | Initial draft                                                              | —                                        | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
