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 inloyalty 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, withis_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_systemseedsredeemableandstatus.program_idpresent 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)
- Tenant provisioning creates exactly one program with
is_default = true, and creates its twois_systemcurrencies. This happens in the same transaction as tenant creation — a tenant without a program is an invalid state, not a state to repair later. - A tenant has exactly one
is_default = trueprogram, enforced by a partial unique index. This holds even once multi-program is enabled. - Every write to any loyalty table resolves
program_idbefore 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. point_currency_kindsis 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 withUNSUPPORTED_CODE.- A program’s currencies cannot be deleted once any ledger transaction references them. They are
deactivated (
is_active = false), never removed. - 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).
- 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.
- 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)
- Provisioning a fresh tenant creates exactly one program and exactly two currencies, in the same transaction. Rolling back tenant creation leaves no orphan program.
- Attempting to insert a second
is_default = trueprogram for the same tenant fails on the partial unique index — proven by a test that expects the constraint violation. - RLS denial test: a query under tenant A’s context returns zero rows from tenant B’s program.
- The
is_systemseeds generate a TS union containing exactly'redeemable' | 'status', andpackages/corebuilds against it. - Posting an unknown currency kind to any endpoint returns
UNSUPPORTED_CODE, not a 500 and not a silent insert. - Creating a program beyond the entitlement is rejected with a typed error naming the limit, and no row is written.
- A request omitting
program_idresolves the default; the same request with an explicit id addresses that program. Both proven over the module’s endpoint set. - Two programs of the same tenant keep entirely independent balances: earning in one leaves the other’s balance unchanged.
- 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 underdatabase/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: