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

# Balance Projection and Drift Reconciliation

> After this ships, a member's balance can be read in milliseconds without touching the ledger, and we can prove that the fast number and the true number agree.

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

1. 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.
2. The row is derived state. It may be dropped and rebuilt at any time; nothing may hold data that
   exists only here.
3. Reconciliation recomputes from `point_lots` and 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.
4. Reconciliation frequency: nightly by default, down to hourly with the freshness add-on. The
   projection's own latency never changes with the tier.
5. 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.
6. `pending` points are excluded from the available balance and exposed as a separate figure.

## Data *(normative)*

| Table                      | Key invariants                                                                                                                                                                                                                    |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.contact_balances` | PK (`program_id`, `contact_id`, `point_currency_id`); `points_available`, `points_pending`, `points_lifetime_earned`, `updated_at`; `points_available` never negative (check constraint); tenant-scoped with RLS; not partitioned |

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. Consumes `loyalty.points.*` for cache invalidation only.

## Acceptance criteria *(normative)*

1. An earn and its balance update are visible in the same transaction: a reader inside the
   transaction sees both or neither.
2. Balance read p95 \<150 ms with a cold cache over a seeded dataset of 100 000 contacts.
3. Seeded drift (a lot mutated directly in SQL) is detected by reconciliation, alerted, and **not**
   auto-corrected.
4. Full rebuild over 100 000 contacts reproduces every balance exactly, verified against a
   ledger-derived control set.
5. `points_available` cannot be driven negative — proven by the check-constraint violation test.
6. **Negative:** no code path updates `contact_balances` outside 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 in
`backend/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`](/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.*
