Context
Expiration is the mechanism that keeps the liability bounded and gives points urgency. Silent expiration is the version that generates complaints and regulatory attention; warned expiration is one of the highest-performing retention campaigns a loyalty program has. The feature is separate from the ledger (FS-LOY-0002) because the failure modes are unrelated. The ledger must expire points correctly; this feature must find members who are about to lose them and tell them, at a scale where a naive scan over the whole ledger every night stops working.Scope (normative)
- Scheduled scan for lots expiring inside a configurable horizon.
loyalty.points.expiring_soonemitted once per member per lot cohort per horizon.- Per-program horizon configuration, with multiple horizons allowed (30 and 7 days, say).
loyalty.expiry_scan_runsrecording each run, its cohort and its outcome.- Per-tenant fan-out so one large tenant cannot starve the rest.
- Idempotency guaranteeing a member is warned once per cohort, whatever happens to the job.
Non-scope (normative)
- Expiring the points. The ledger does that (FS-LOY-0002); this feature only warns.
- The campaign and its content —
messagingconsumes the event. - Member-facing expiry display, which reads the projection.
Behaviour (normative)
- The scan runs per tenant per program, on cron fan-out. It never runs as one global query.
- A member is warned once per (lot cohort, horizon). Re-running the scan on the same day, or replaying the job, produces no second event.
- Multiple horizons are independent: a member can be warned at 30 days and again at 7.
- The event carries the total expiring, the currency, the expiry date and the cohort id — enough
for
messagingto render without querying back. - Warnings are category
marketingand therefore obey consent, suppressions, quiet hours and frequency caps (ADR-019). FORBIDDEN: sending an expiry warning as transactional to bypass an opt-out. It is a retention message, and the member said no. - Scan frequency follows the tenant’s freshness tier (DEC-G5); the default is nightly.
- A member with no reachable channel is still recorded as scanned. The scan reports what it found, independent of delivery.
- The scan is read-only against the ledger. It never mutates lots.
Data (normative)
API (normative)
Events (normative)
loyalty.points.expiring_soon, available as an outgoing webhook. Payload: contact_id,
points_amount, point_currency_id, expires_at, cohort_key, correlation_id.
Acceptance criteria (normative)
- A member with a lot expiring in 30 days is warned exactly once when the 30-day horizon runs.
- Re-running the same scan the same day emits zero additional events.
- Two horizons produce two events on their respective days, and neither suppresses the other.
- The scan over 500 000 lots completes within the documented budget without exceeding the per-tenant query budget.
- A member who opted out of marketing receives no message, while the event is still emitted and the scan still records them.
- Job replay after a mid-run crash resumes without duplicating warnings.
- Negative: the scan performs no write against
point_lotsorledger_transactions— proven by a read-only database role in the test.
Execution
Asynchronous pipeline. Scheduled fan-out inbackend/scheduler, the scan itself in
backend/workers, idempotent via core.processed_jobs plus the expiry_scan_runs fence.
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.