Context
The ledger (FS-LOY-0002) is correct but expensive to read: computing a balance means summing lots. On the hot path — a member opening the portal, a point of sale checking eligibility — that is not acceptable at the p95 budget. The projection is where most loyalty systems quietly break. If it is updated asynchronously, the member redeems, refreshes, and sees the old number; if it is allowed to drift, nobody can say which of the two figures is real. ADR-011 settles both: the projection is written in the same transaction as the ledger, and a scheduled job proves it still matches. DEC-G5 draws a line that matters commercially: the data-freshness add-on raises the frequency of reconciliation, never of the balance itself. Charging a tenant for their members seeing a correct balance would be charging for correctness.Scope (normative)
loyalty.contact_balances: one row per (program, contact, point currency).- Projection updated inside the ledger write transaction.
- Nightly reconciliation recomputing balances from lots and reporting divergence.
- Reconciliation frequency driven by the tenant’s data-freshness entitlement.
- A documented, runnable rebuild procedure for the whole projection.
- Read path with Redis caching, invalidated by the ledger’s outbox event.
Non-scope (normative)
- Reading balances through the public API — that is the member-facing endpoint in FS-LOY-0006.
- Tier progress, which is a different projection over status points (FS-LOY-0010).
- Analytical aggregates and liability reporting — later, and they read the ledger, not this.
Behaviour (normative)
- The projection is updated in the same transaction as the ledger write. Never a queue job, never eventual. FORBIDDEN: a code path that writes a ledger row without updating the balance.
- The row is derived state. It may be dropped and rebuilt at any time; nothing may hold data that exists only here.
- Reconciliation recomputes from
point_lotsand compares. A divergence is reported and alerted, never silently corrected — a silent fix destroys the evidence of the bug that caused it. Correcting it is a deliberate operation with an audit entry. - Reconciliation frequency: nightly by default, down to hourly with the freshness add-on. The projection’s own latency never changes with the tier.
- Cached reads are invalidated by the existing cache-invalidation consumer of the outbox. A cache miss falls through to the projection, never to the ledger.
pendingpoints are excluded from the available balance and exposed as a separate figure.
Data (normative)
Rebuild procedure: truncate for the tenant, recompute from
point_lots grouped by contact and
currency, re-derive points_lifetime_earned from earn transactions. Documented in the module
spec and exercised by the tenant-restore runbook.
API (normative)
None. The projection is read by other features; exposing it directly would let a caller bypass the permission and consent checks that belong to the member endpoint.Events (normative)
None emitted. Consumesloyalty.points.* for cache invalidation only.
Acceptance criteria (normative)
- An earn and its balance update are visible in the same transaction: a reader inside the transaction sees both or neither.
- Balance read p95 <150 ms with a cold cache over a seeded dataset of 100 000 contacts.
- Seeded drift (a lot mutated directly in SQL) is detected by reconciliation, alerted, and not auto-corrected.
- Full rebuild over 100 000 contacts reproduces every balance exactly, verified against a ledger-derived control set.
points_availablecannot be driven negative — proven by the check-constraint violation test.- Negative: no code path updates
contact_balancesoutside the ledger service. Enforced by a lint rule restricting writes to that module.
Execution
Ships in TS-004 alongside the ledger service. The reconciliation job lives inbackend/scheduler, idempotent via core.processed_jobs.
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.