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

# Programs and Point Currencies

> After this ships, a tenant can create and name loyalty programs with their own point currencies, and every other loyalty table has a program to belong to.

## Context

A program is the container for everything else in this module: rules, currencies, tiers and the
reward catalog all hang off it. Nothing else in `loyalty` can be built until it exists.

The decision that shapes this feature is **ADR-021: multi-program is a first-class capability in
F1**, superseding the earlier position that it would be schema-only. A holding with two brands, a
company running a B2C program alongside a B2B one for distributors, or a seasonal campaign that
must not contaminate the main program are ordinary situations, and a customer asking on the first
sales call should not hear "later". Program count is also an honest plan dimension: the base plan
includes one, and needing a second is a concrete reason to move up a tier.

The schema cost was already paid — `program_id` was always going to be on every loyalty table from
the first migration. What ADR-021 buys back is console and API surface, which only gets more
expensive to retrofit as screens accumulate.

The second decision is ADR-011's dual currency: redeemable points and status points are separate
currencies and are never mixed. This is the airline model — miles you spend versus miles that
qualify you for a tier — and conflating them is the single most common way loyalty programs become
impossible to reason about.

## Scope *(normative)*

* `loyalty.programs`: one row per program, tenant-scoped, tenant-named, with `is_default`.
* Full CRUD: create, rename, activate and deactivate, subject to the tenant's entitlement.
* Automatic creation of the default program when a tenant is provisioned.
* `loyalty.point_currencies`: at least two per program, one of each kind.
* `loyalty.point_currency_kinds`: parametric catalog, `is_system` seeds `redeemable` and `status`.
* `program_id` present and NOT NULL on every loyalty table created by this or any later FS.
* Resolution helper: given a tenant context with no explicit program, resolve the default.
* Entitlement check on program count, blocking rather than billing an overage.
* Management endpoints for programs and currencies.

## Non-scope *(normative)*

* **Deleting a program.** Programs are deactivated, never deleted: their ledger rows, tiers and
  redemptions must stay resolvable forever.
* **Cross-program aggregation in reporting.** Each program reports on itself (ADR-021). Aggregating
  raises real questions — whose points, which currency, which tier ladder — that deserve their own
  decision.
* Currency conversion between programs, or between point currencies. Not planned.
* Earning or spending anything — that is FS-LOY-0002 onward.
* Tier definitions, which reference currencies but belong to FS-LOY-0010.

## Behaviour *(normative)*

1. Tenant provisioning creates exactly one program with `is_default = true`, and creates its two
   `is_system` currencies. This happens in the **same transaction** as tenant creation — a tenant
   without a program is an invalid state, not a state to repair later.
2. A tenant has exactly one `is_default = true` program, enforced by a partial unique index. This
   holds even once multi-program is enabled.
3. Every write to any loyalty table resolves `program_id` before it touches the database. There is
   no "null means default" fallback at the data layer — the resolution happens once, at the edge.
   A request that omits the program resolves the tenant's default, so a single-program integration
   never has to know the concept exists.
4. `point_currency_kinds` is **not tenant-extensible** (DEC-B4): the kind governs system logic, so
   a new kind is a code change plus a spec change, never a row insert. The API rejects unknown
   kinds with `UNSUPPORTED_CODE`.
5. A program's currencies cannot be deleted once any ledger transaction references them. They are
   deactivated (`is_active = false`), never removed.
6. Creating a program beyond the tenant's entitlement is **blocked with a typed error naming the
   limit**, never billed as an overage. A capability the tenant chooses to adopt gets a hard limit;
   overage pricing is for consumption their customers' behaviour drives (ADR-021).
7. A program's expiration and return-window policies are its own. Two programs of the same tenant
   may run entirely different rules — that is the point of having two.
8. FORBIDDEN: a loyalty table without `program_id` · a single currency serving both redeemable and
   status purposes · points, tiers or rewards crossing a program boundary.

## Data *(normative)*

| Table                          | Key invariants                                                                                                                                                              |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.programs`             | tenant-scoped (`tenant_id`, `cell_id`, RLS); `is_default` unique per tenant via partial index; `name` set by the tenant, unique per tenant; `is_active`; never hard-deleted |
| `loyalty.point_currencies`     | FK `program_id`; FK `kind_code` → `point_currency_kinds`; `code` unique per program; `label`; `is_active`; at least one currency of each kind per program                   |
| `loyalty.point_currency_kinds` | parametric per `standards/data.md`: `code` TEXT PK, `label`, `is_system`, `is_active`, `sort_order`, `metadata`. Seeds: `redeemable`, `status`. Not tenant-extensible.      |

Neither table is partitioned — both are small and regular (`standards/data.md` §4).
`point_currency_kinds` is cached and must never be joined on a hot path; its `is_system` rows
generate the TS/Zod union consumed by `packages/core`.

## API *(normative)*

| Endpoint                           | Class      | Permission                | Budget    |
| ---------------------------------- | ---------- | ------------------------- | --------- |
| `GET /v1/loyalty/programs`         | Management | `loyalty.programs.read`   | p95 \<1 s |
| `POST /v1/loyalty/programs`        | Management | `loyalty.programs.create` | p95 \<1 s |
| `PATCH /v1/loyalty/programs/{id}`  | Management | `loyalty.programs.update` | p95 \<1 s |
| `GET /v1/loyalty/programs/current` | Management | `loyalty.programs.read`   | p95 \<1 s |
| `GET /v1/loyalty/currencies`       | Management | `loyalty.currencies.read` | p95 \<1 s |

`programs/current` stays as the shortcut for the default, so an integration that only ever has one
program never handles an id. `program_id` is a normal, documented parameter everywhere else.

## Events *(normative)*

`loyalty.program.created` when a tenant creates one beyond the default. The default program's own
creation emits nothing — it is part of tenant provisioning and covered by that flow's audit entry.

## Acceptance criteria *(normative)*

1. Provisioning a fresh tenant creates exactly one program and exactly two currencies, in the same
   transaction. Rolling back tenant creation leaves no orphan program.
2. Attempting to insert a second `is_default = true` program for the same tenant fails on the
   partial unique index — proven by a test that expects the constraint violation.
3. RLS denial test: a query under tenant A's context returns zero rows from tenant B's program.
4. The `is_system` seeds generate a TS union containing exactly `'redeemable' | 'status'`, and
   `packages/core` builds against it.
5. Posting an unknown currency kind to any endpoint returns `UNSUPPORTED_CODE`, not a 500 and not
   a silent insert.
6. Creating a program beyond the entitlement is rejected with a typed error naming the limit, and
   no row is written.
7. A request omitting `program_id` resolves the default; the same request with an explicit id
   addresses that program. Both proven over the module's endpoint set.
8. Two programs of the same tenant keep entirely independent balances: earning in one leaves the
   other's balance unchanged.
9. **Negative:** no operation moves points, a tier or a reward across a program boundary.

## Execution

Single slice, synchronous command archetype. Ships as part of TS-001 (foundational migration):
Drizzle definitions under `database/postgres/src/schema/loyalty/`, the seeds in
`database/postgres/seeds/`, the provisioning hook alongside tenant creation, and the two read
endpoints in `backend/api`. No worker, no queue, no cron.

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

Newly opened by the same decision:

| # | Question                                                    | Decides | By                                               |
| - | ----------------------------------------------------------- | ------- | ------------------------------------------------ |
| 1 | How many programs does the base plan include — one, or two? | Daniel  | before commercial launch; does not block this FS |

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