Skip to main content

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)

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)

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, v1.1) and folded into the normative sections above.

Changelog

Delivery record

Not implemented yet.