# Offering & Registration Model Phase 7 Steps 3/6/12-14/18-20 — the schema and lifecycle for offerings, capacity, registrations, agreements, and curriculum packages. See `platform/api/migrations/034_phase7_offerings_ registrations_agreements.sql` for the exact DDL and `platform/api/src/offerings.js` for the implementation. ## Offering lifecycle (server-defined transitions only) ``` draft ──► published ──► enrollment_open ──┬──► full ──► enrollment_open (reopen) │ │ │ │ │ │ ├──► in_progress ──► completed │ │ │ └──► cancelled ◄──────────────────────────────────────────────────────┘ ``` No route accepts an arbitrary client-supplied status — `offerings.transitionOffering()` checks a fixed transition table and rejects anything not explicitly allowed with `409 INVALID_TRANSITION`. `full` is a distinct status from `enrollment_open` (not a computed flag) so an admin can see at a glance why enrollment stopped, and can explicitly reopen it (e.g. a seat freed up) rather than have it silently reappear. ## Capacity — race-safe by construction `offerings.capacity` is nullable (NULL = unlimited, the normal case for `self_paced`). `offerings.reserved_seats` is a maintained counter, incremented by exactly one SQL statement: ```sql UPDATE offerings SET reserved_seats = reserved_seats + 1 WHERE offering_id = $1 AND reserved_seats < capacity RETURNING *; ``` If zero rows come back, the offering is genuinely full — `409 OFFERING_FULL`. This is not a `SELECT count(*) ... then INSERT` pattern (the exact anti-pattern the authorization named): the row-level lock Postgres takes for the UPDATE's own duration makes two concurrent registration attempts for the last seat serialize correctly, with exactly one succeeding, never both. **A real ordering bug found and fixed during this phase's own testing**: the registration route originally reserved the seat *before* validating the registration's own fields (e.g. a minor registration missing a `guardianId`). Under a full offering, that let a plain validation error get masked behind a false `OFFERING_FULL`, and even when the check passed, a request that would later fail validation had already consumed a real seat. Fixed by creating (and validating) the registration row first, then reserving the seat, rolling back (deleting) the registration row if the seat reservation itself then fails — see `admin-offerings-routes.js`'s own comment. ## Registration lifecycle ``` started ──► submitted ──► reviewed ──┬──► agreements_pending ──┬──► payment_pending ──► confirmed ──► enrollment_activated │ │ │ │ │ └──────────────────► confirmed ◄────────────────┘ └──► cancelled (from any non-terminal state; releases the reserved seat) ``` Not every offering needs every stage — a simple self-paced offering can go `started` → `confirmed` → `enrollment_activated` directly; a stage is a real state a registration CAN pass through, not one it MUST. `enrollment_activated` and `cancelled` are terminal. A registration may exist with `student_id = NULL` (a guardian registering a not-yet-created student). `convertRegistrationToEnrollment()` is the one place a registration becomes a real academic `enrollment` row — it reuses `enrollment.js`'s existing `resolveOrCreateStudent`/ `createManualEnrollment` rather than duplicating student-matching or enrollment-creation logic, and creates `cohort_enrollments` membership too if the offering has a `cohort_id`. ## Minor registrations `offering_registrations.is_minor = true` requires a non-null `guardian_id` — enforced by a database CHECK constraint, not just application logic, so it holds even against a direct/future API caller that skips the usual route. The guardian is a real `guardians` row (Phase 7's new model), never a duplicated email on the eventual student record. ## Agreements — acceptance records, not legal text `agreement_definitions` stores a title and a plain-language `body_summary` for admin-UI display — explicitly **not** attorney-approved legal language (see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s legal-review list). `agreement_acceptances` snapshots the `accepted_version` at acceptance time (not just a live foreign key to the current version), so a later edit to an agreement's text never retroactively reinterprets what someone already agreed to — the same principle `COURSE_CONTENT_MODEL.md` §6b already established for assessment session versioning. An acceptance is tied to a student OR a guardian (a CHECK constraint requires at least one), and optionally to the registration/enrollment context it was collected for. ## Curriculum packages — reference, never clone A `curriculum_package` names a versioned subset of an existing program's courses via `curriculum_package_items` (`course_id` + `sequence`) — a pure reference list, storing no content of its own. Worked example, matching `docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s Option A: ``` curriculum_package_id: winter-linux-systems-v1 program_id: root-kubernetes-foundations items: [ (root-01-computing-linux-foundations, seq 1), (root-02-linux-administration-networking, seq 2) ] ``` An offering referencing this package still points its students at the real, unmodified ROOT 01/ 02 courses — nothing is cloned, no activity is duplicated, and any future content fix to ROOT 01/ 02 is automatically reflected for every offering referencing the package. Historical offerings stay stable against a **specific version** of the package (bump `curriculum_packages.version` for a semantic change, exactly like every other versioned row in this schema) rather than silently picking up a redefinition. ## Offering → cohort For cohort/camp/workshop delivery, an offering's `cohort_id` (nullable) points at an existing `cohorts` row, unchanged from Phase 2A. `convertRegistrationToEnrollment()` inserts the matching `cohort_enrollments` row when present — a student registering into a cohort-backed offering ends up on the real classroom roster automatically, with zero duplicated roster state. A `self_paced` offering simply has `cohort_id = NULL`; there is no cohort to join. ## What was deliberately not built Payment processing, a public-facing registration form, pricing display, and subscription billing — all explicitly out of Phase 7's scope. `offerings.metadata` (JSONB) has room for future descriptive pricing/marketing fields, but nothing writes payment data into it.