Context
This is the endpoint a cashier’s terminal calls with a customer standing at the counter. It is the most operationally hostile path in the module: unreliable networks, retried requests, cancelled sales, and a human waiting. Three properties are non-negotiable. Idempotency, because a POS will retry and a double redemption is a customer-service incident. Atomicity across several rewards, because a basket can redeem more than one thing and half a redemption is worse than none. And rollback, because sales get voided and the points must come back with a decision about the lots they consumed. DEC-H3 confirmed mixed points + money as a real requirement — it is a contract-winning feature. F1 does not build it, but leaves the seam: a nullablemoney_component and a domain event shaped
so that adding payment later is an addition rather than surgery.
Scope (normative)
loyalty.redemptions, parent and child rows from the first migration.Idempotency-Keyhandling with a 24-hour stored result.- Validation against balance, reward availability, stock and per-contact limits.
- Ledger
redeemtransactions consuming lots FIFO, through FS-LOY-0002’s service. - Rollback with two explicit modes:
revert(return the points) andkeep(do not). money_component, nullable, unused in F1.- The member-facing
members/meread: balance, tier, active coupons.
Non-scope (normative)
- Stacking policy across multiple discounts — FS-LOY-0008. A redemption here applies what it is told to; deciding what may be combined is a policy layer above.
- Coupon redemption — FS-LOY-0007. Coupons and points are different instruments.
- Any payment execution. The seam is reserved, not wired.
Behaviour (normative)
Idempotency-Keyis required. The same key replays the stored result for 24 hours, byte for byte, without re-executing anything. A different payload under the same key is a typed conflict error, never a silent overwrite.- A redemption is one transaction: validation, ledger
redeemrows, lot consumption, stock decrement, projection update, outbox event and audit row commit together. - Multi-reward requests are parent/child. The parent is all-or-nothing: if any child fails, nothing is applied.
- Validation happens before any write. Insufficient balance, an inactive or out-of-stock reward, or an exhausted per-contact limit fails cleanly with a typed error and zero side effects.
- Rollback is at parent level with an explicit mode.
revertwrites compensating ledger transactions that restore the points; consumed lots are restored to their original lots whenever those lots have not expired, and to a new lot inheriting the earliest expiry when they have.keepleaves the points spent and records the decision. There is no default — the caller states the mode. - A redemption is never deleted. Rollback is a state transition plus compensating transactions.
money_componentis nullable and MUST be null in F1. A non-null value is rejected until the payment seam is implemented.- Budget: p95 <300 ms for the synchronous decision (DEC-D7).
Data (normative)
API (normative)
Events (normative)
loyalty.points.redeemed on a successful parent, carrying the redemption id, rewards, total cost
and correlation_id. A rollback emits it again with a reverted marker rather than inventing a
second event name — a consumer that understands redemption already understands its reversal.
Acceptance criteria (normative)
- The same
Idempotency-Keysent 100 times concurrently produces exactly one redemption and one set of ledger rows. - A different payload under an already-used key returns a typed conflict, and the original redemption is unchanged.
- A three-reward parent where the third exceeds stock applies nothing: no ledger rows, no stock decrement, no partial parent.
revertrestores the exact point total; the sum over the member’s ledger returns to its pre-redemption value. Restored points landing in a new lot inherit the earliest expiry of the lots consumed.keepleaves the balance unchanged and records the mode in the redemption row.- p95 <300 ms under the k6 profile with a 10 000-contact dataset.
- Negative: a request with a non-null
money_componentis rejected in F1 with a typed error, not silently ignored.
Execution
Synchronous command archetype. Ships as its own slice after TS-004: the endpoint inbackend/api,
the domain service beside the ledger service, no worker involved — this path is synchronous by
design because the cashier is waiting.
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.