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)
- 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.
- 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.
- Precedence is explicit and total: categories carry a
sort_orderand ties break on category code. There is no scenario where the outcome depends on request ordering. - Every exclusion returns a typed reason. “Not allowed” without a reason is unusable at a counter and unusable in support.
- Evaluation runs inside redemption validation, before any write. FORBIDDEN: applying benefits and then reversing the ones that should not have stacked.
discount_categoriesis 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)
API (normative)
Events (normative)
None. Policy changes are administrative and covered by the audit log.Acceptance criteria (normative)
- A new program stacks everything by default, verified on a freshly provisioned tenant.
- Mode ALL permits every combination; PARTIAL with a non-combinable set
{tier_discount, coupon}rejects exactly that pair and permits every other. - Evaluation is order-independent: the same benefit set in six different orders yields the same allowed subset.
- Every exclusion carries a typed reason naming the rule that excluded it.
- 100% branch coverage on the pure evaluation function.
- 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 inpackages/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, v1.1) and folded
into the normative sections above.