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_systemseedsaward_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_executionsfor 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_couponarrives with FS-LOY-0007,apply_discountis 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
coreand evaluated there. - Ingestion of the event itself, which is TS-003.
Behaviour (normative)
- 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.
- Publishing invalidates the tenant’s compiled-rules cache. A rule takes effect for events arriving after publication, never retroactively.
- 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.
- 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. - A campaign’s budget is checked and decremented inside the effect transaction. Overrunning a budget is impossible, not unlikely.
- 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.
- Adding a row to
rule_effect_typesdoes not add behaviour. The API rejects an unsupported effect code withUNSUPPORTED_CODEat rule-publish time, not at execution time. - Evaluation happens in the processor, off the hot path. The ingestion endpoint has already answered 202.
Data (normative)
API (normative)
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)
Emitsloyalty.points.earned through the ledger service when award_points executes. Consumes
core.event.tracked from the ingestion pipeline.
Acceptance criteria (normative)
- A published rule “1 point per 1000 currency units on
invoice_paid” applied to a 45 990 CLP event awards exactly 45 points. - Replaying the same event 100 times produces exactly one ledger row and one
rule_executionsrow — the replay-storm test. - Editing a published rule creates version 2; an execution recorded under version 1 still reports the conditions that were actually applied.
- A campaign with
budget_total = 1000cannot exceed it under 50 concurrent qualifying events. - Publishing invalidates the cache: an event arriving immediately after publication matches the new rule; an event that arrived before does not.
simulatereturns the effects and writes nothing — verified by a ledger row count before and after.- 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 inbackend/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, v1.1) and folded
into the normative sections above.