docs(phase7): document the business/product architecture foundation

New: ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md (canonical business/platform
bridge — course families, delivery modes, BUILT NOW/PHASE 7 BUILT/FUTURE), OFFERING_AND_
REGISTRATION_MODEL.md (lifecycle diagrams, capacity mechanics), STUDENT_GUARDIAN_DATA_
MODEL.md (data minimization, RBAC, legal-review items), SCHOOL_AND_COMMUNITY_DELIVERY_
ARCHITECTURE.md (organization/location mapping, explicitly not multi-tenancy),
PHASE7_PRODUCT_ARCHITECTURE.md (phase summary).

Updated: WEBSITE_ENROLLMENT_INTEGRATION.md (documented future offering/registration flow,
current live contract unchanged), platform/README.md (test list).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-26 22:44:13 -05:00
co-authored by Claude Sonnet 5
parent 7d870d8e2b
commit df5e39f767
7 changed files with 591 additions and 4 deletions
+117
View File
@@ -0,0 +1,117 @@
# 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.