Skip to main content

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)

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)

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, v1.1) and folded into the normative sections above. Newly opened by the same decision:

Changelog

Delivery record

Not implemented yet.