# Phase 7.1 — Membership Engine

| Field | Value |
| --- | --- |
| Identifier | `loyalty-7.1` |
| Status | Complete |
| Module | loyalty |
| Service | `loyalty-service` |
| Version | `0.7.1.0` |
| Database | `loyalty_db` |
| Depends On | Phase 7.0 |
| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 |
| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) |

## Objective

Deliver the Membership Engine: lifecycle state machine, eligibility transitions, append-only lifecycle history, program cascade policy, and lifecycle APIs/events — without points ledger, rewards redemption, or campaign engines.

## Scope

### In Scope

- Member lifecycle: activate, renew, freeze, resume, cancel, expire, transfer
- Enroll path hardened with term / expiry and lifecycle events
- `MembershipLifecycleEvent` append-only history
- Cascade policy: reject program soft-delete while blocking members exist
- Permissions, events, validators, migration `0002_phase_71_membership`
- Tests + documentation + handover

### Out of Scope

- Point ledger / balances (7.2)
- Rewards redemption (7.3)
- Campaign engine (7.4+)
- Wallet / gift cards / partner network

## Cascade Policy

Soft-deleting a `LoyaltyProgram` is **rejected** (`409 program_has_active_members`) when any non-deleted member exists in statuses: `pending`, `active`, `frozen`, `suspended`, `expired`. Cancelled / transferred / closed members do not block.

## State Machine

| Action | Allowed from | To |
| --- | --- | --- |
| enroll / activate | pending (activate also expired) | active |
| renew | active, expired, frozen, suspended | active |
| freeze | active | frozen |
| resume | frozen, suspended | active |
| cancel | pending, active, frozen, suspended, expired | cancelled |
| expire | active, frozen, suspended | expired |
| transfer | active, frozen, suspended | transferred (+ new active member in target program) |

Terminal: `cancelled`, `transferred`, `closed`.

## Models

| Entity | Soft delete | Audit | Tenant |
| --- | --- | --- | --- |
| Member (extended fields) | Yes | Yes | Yes |
| MembershipLifecycleEvent | No (append-only) | Via fields | Yes |

New member fields: `activated_at`, `membership_started_at`, `membership_expires_at`, `frozen_at`, `freeze_reason`, `cancelled_at`, `cancel_reason`, `expired_at`, `transferred_to_member_id`.

## APIs

| Method | Path | Permission |
| --- | --- | --- |
| POST | `/api/v1/members/{id}/activate` | `loyalty.members.activate` |
| POST | `/api/v1/members/{id}/renew` | `loyalty.members.renew` |
| POST | `/api/v1/members/{id}/freeze` | `loyalty.members.freeze` |
| POST | `/api/v1/members/{id}/resume` | `loyalty.members.resume` |
| POST | `/api/v1/members/{id}/cancel` | `loyalty.members.cancel` |
| POST | `/api/v1/members/{id}/expire` | `loyalty.members.expire` |
| POST | `/api/v1/members/{id}/transfer` | `loyalty.members.transfer` |
| GET | `/api/v1/members/{id}/lifecycle` | `loyalty.members.lifecycle.view` |

Existing create / enroll / update / delete remain compatible (additive fields).

## Events

| Event | When |
| --- | --- |
| `loyalty.member.activated` | Activate / enroll activation |
| `loyalty.member.renewed` | Renew |
| `loyalty.member.frozen` | Freeze |
| `loyalty.member.resumed` | Resume |
| `loyalty.member.cancelled` | Cancel |
| `loyalty.member.expired` | Expire |
| `loyalty.member.transferred` | Transfer (source) |

## Migration

| Item | Detail |
| --- | --- |
| Alembic | `0002_phase_71_membership` (down_revision `0001_initial`) |
| Breaking | None — additive columns + new table |
| Backfill | None required |

## Tests

Command: `cd backend/services/loyalty && pytest -q` → **59 passed**

## Known Limitations

- No automatic expire scheduler (API-driven expire only)
- Transfer copies profile/contact; does not move point accounts (7.2)
- JWT↔tenant binding remains platform-wide

## Next Phase

Phase 7.2 — Point Engine (immutable ledger)

## Related Documents

- [Handover](phase-handover/phase-7-1.md)
- [Phase 7.0](loyalty-phase-7-0.md)
- [ADR-011](architecture/adr/ADR-011.md)
- [Module Registry](module-registry.md#loyalty)
