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

# Stacking Rules

> After this ships, a tenant can state which benefits may be combined, and the engine enforces it instead of the cashier improvising.

## Context

The moment a program has more than one instrument — points, coupons, tier discounts — the question
"can these be used together?" appears, and it is a margin question. Without an explicit policy the
answer is decided ad hoc at the counter, which is how programs lose money quietly.

This is deliberately **not** part of coupons or of redemptions. It is a policy layer over both, and
forcing it into either one produces a spec that cannot be approved because half its subject matter
lives elsewhere. Keeping it separate is also what lets F1a ship without it: until a tenant has two
combinable instruments, the policy is trivially "yes".

## Scope *(normative)*

* `loyalty.discount_categories`: parametric, tenant-extensible, with a precedence order.
* `loyalty.stacking_rules`: an ALL or PARTIAL policy per program, plus non-combinable sets.
* Evaluation inside redemption validation: given a set of intended benefits, return the allowed
  subset and the reason for each exclusion.
* Deterministic precedence when two benefits conflict.

## Non-scope *(normative)*

* Computing the resulting price. That is `apply_discount`, F2.
* Cross-tenant or cross-program stacking. A benefit never stacks across programs.
* Automatic optimisation for the member. The engine applies the policy; it does not search for the
  combination that would benefit the customer most. That is a deliberate F2+ question.

## Behaviour *(normative)*

1. The default policy for a new program is **PARTIAL with no non-combinable sets** — everything
   stacks until the tenant says otherwise. The permissive default is chosen because the restrictive
   one silently breaks existing programs on upgrade.
2. Evaluation is a **pure function** over the intended benefits and the program's policy. No I/O,
   fully unit-testable, deterministic for a given input.
3. Precedence is explicit and total: categories carry a `sort_order` and ties break on category
   code. There is no scenario where the outcome depends on request ordering.
4. Every exclusion returns a **typed reason**. "Not allowed" without a reason is unusable at a
   counter and unusable in support.
5. Evaluation runs inside redemption validation, before any write. FORBIDDEN: applying benefits and
   then reversing the ones that should not have stacked.
6. `discount_categories` is tenant-extensible — it is the tenant's commercial vocabulary. The
   stacking policy itself is not extensible: ALL and PARTIAL are the only modes.

## Data *(normative)*

| Table                         | Key invariants                                                                                                                     |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.discount_categories` | parametric, **tenant-extensible**; `code`, `label`, `sort_order` mandatory and unique per program                                  |
| `loyalty.stacking_rules`      | `program_id`; `mode` ALL or PARTIAL; `non_combinable_sets` JSONB, each a set of category codes; exactly one active row per program |

## API *(normative)*

| Endpoint                                   | Class      | Permission                              | Budget    |
| ------------------------------------------ | ---------- | --------------------------------------- | --------- |
| `GET/PUT /v1/loyalty/stacking-rules`       | Management | `loyalty.stacking_rules.{read\|update}` | p95 \<1 s |
| `POST /v1/loyalty/stacking-rules/simulate` | Management | `loyalty.stacking_rules.simulate`       | p95 \<1 s |

## Events *(normative)*

None. Policy changes are administrative and covered by the audit log.

## Acceptance criteria *(normative)*

1. A new program stacks everything by default, verified on a freshly provisioned tenant.
2. Mode ALL permits every combination; PARTIAL with a non-combinable set `{tier_discount, coupon}`
   rejects exactly that pair and permits every other.
3. Evaluation is order-independent: the same benefit set in six different orders yields the same
   allowed subset.
4. Every exclusion carries a typed reason naming the rule that excluded it.
5. 100% branch coverage on the pure evaluation function.
6. **Negative:** no redemption applies a benefit that evaluation excluded — proven by a test that
   attempts it directly against the service.

## Execution

Single slice, synchronous command. The evaluator lives in `packages/core` beside `resolveChannels`
— both are pure policy functions with the same testing discipline.

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