diff --git a/docs/OFFERING_AND_REGISTRATION_MODEL.md b/docs/OFFERING_AND_REGISTRATION_MODEL.md new file mode 100644 index 0000000..3e11de8 --- /dev/null +++ b/docs/OFFERING_AND_REGISTRATION_MODEL.md @@ -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. diff --git a/docs/PHASE7_PRODUCT_ARCHITECTURE.md b/docs/PHASE7_PRODUCT_ARCHITECTURE.md new file mode 100644 index 0000000..231422a --- /dev/null +++ b/docs/PHASE7_PRODUCT_ARCHITECTURE.md @@ -0,0 +1,151 @@ +# Phase 7 — Business & Product Architecture Foundation + +Phase 7's mission: formalize RootNode Academy as a business/product platform capable of a future +catalog across INIT_/COMPILE_/ROOT_/BOOTCAMP_, without rebuilding any proven system and without +authoring new curriculum. + +## Baseline + +Confirmed at start: branch `main`, clean tree, HEAD = `origin/main` = Phase 6F's +`d0664104515e4bd389c8e7bc88293327e7eda100`, tag `v0.6.0-root-v1` present. Reviewed existing +`programs`/`enrollments`/`cohorts`/`classroom_sessions`/`students` schema before designing +anything new — found `programs.family`/`delivery_mode` and `cohorts.seat_target` already +existed, so Phase 7 widened rather than duplicated them where it could. + +## What was built + +- **Organizations, locations** (`033_phase7_organizations_locations_guardians.sql`) — a business/ + delivery relationship and a physical/virtual venue, deliberately not SaaS multi-tenancy. +- **Guardians, student_guardians** (same migration) — the real foundation replacing the Phase + 5C `contact_email` workaround, left functioning unchanged for the live website integration. +- **Offerings, curriculum packages, registrations, agreements** + (`034_phase7_offerings_registrations_agreements.sql`) — the full purchasable-delivery model, + pre-enrollment registration lifecycle, versioned agreement acceptance records, and + reference-only curriculum subsets. +- **`platform/api/src/offerings.js`** — the business logic: server-defined status transitions + (offerings and registrations both), race-safe atomic seat reservation, and + `convertRegistrationToEnrollment()` reusing `enrollment.js`'s existing student-matching and + enrollment-creation logic rather than duplicating it. +- **`platform/api/src/admin-offerings-routes.js`** — the full admin/owner API surface for all of + the above, registered in `server.js` alongside the existing route modules. +- **`programs.family` widened** to accept `bootcamp` (a pure CHECK-constraint widening; no + existing row changes). + +## What was NOT built this phase + +**Admin UI** (Steps 16/17 — Offerings/Registrations screens in `/admin`). This was a deliberate +scope call: given the phase's own emphasis ("BUSINESS DOMAIN MODEL, PRODUCT ARCHITECTURE, +ENROLLMENT/OPERATIONS ARCHITECTURE"), the schema, business logic, RBAC, tests, and a full +working rehearsal were prioritized as the load-bearing deliverable over a polished React +surface. Every capability Steps 16-17 describe is fully implemented and tested at the API layer +— an admin UI is now a bounded, well-specified follow-up (the routes and their exact shapes +already exist), not a design question. + +Also not built, per the explicit DO-NOT-BUILD list: payment processing, subscription billing, +public checkout, INIT_/COMPILE_ curriculum, ROOT 08/11 expansion, a Parent Portal UI, school +SIS integrations, certificates. + +## A real bug found and fixed during this phase's own testing + +The registration-creation route originally reserved an offering's seat *before* validating the +registration's own fields. Under a full offering, this let a plain validation failure (e.g. a +minor registration missing `guardianId`) get masked behind a false `409 OFFERING_FULL`, and even +when capacity wasn't the issue, a request that would fail validation anyway had already consumed +a real seat. Found via this phase's own smoke testing before any commit; fixed by validating +(creating) the registration first, then reserving the seat, rolling back the registration row if +reservation then genuinely fails. See `docs/OFFERING_AND_REGISTRATION_MODEL.md`. + +## Testing + +`platform/test/e2e-phase7.js` — 17/17 passing (fresh stack): course-family/age-guidance +independence from authorization, offerings across two distinct locations, curriculum-package +content-reference integrity (including rejecting a fabricated course id), atomic capacity +enforcement with correct validate-before-reserve ordering, instructor assignment, guardian +creation/linking, minor-vs-adult registration paths, registration lifecycle transitions +(including a rejected invalid jump), agreement-version-snapshotted acceptance, registration→ +enrollment conversion with correct cohort/no-cohort behavior, curriculum-package version-bump +stability, registration/guardian RBAC (admin/owner only) vs. offering/location visibility (any +instructor, with `access_notes` stripped), explicit age-independence, and confirmation that an +existing ROOT_ V1 self-paced enrollment and an existing classroom/cohort flow are both +completely unaffected. + +## Winter Break Camp rehearsal + +`infra/scripts/drills/test-phase7-winter-camp.js` — 18/18 passing against a real, freshly-built +stack, with **no payment code invoked anywhere**: admin creates a real organization/location, a +curriculum package referencing the existing ROOT 01-02 courses (matching `docs/ +WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s Option A exactly), a real cohort, and a DRAFT offering +with dates/price/exact seat count left unset as authorized; a guardian registers a minor and an +adult registers themselves; both move through submission, agreement acceptance, and a simulated +admin-side commerce confirmation; both convert to real enrollments and real cohort membership +with real one-time activation tokens; both activate real accounts; the instructor starts a real +classroom session and Prepares Labs; the minor student joins, completes real ROOT 01 orientation +and lesson activities, and passes a REAL Lab task (`linux-inspect-hardware`) inside a real +disposable Lab container — with the pass then confirmed to survive a real logout/login; the +instructor sees real roster state; the offering moves through `in_progress` to `completed`. + +## ROOT_ V1 regression + +`e2e-phase6f.js` rerun fresh: 17/17 unaffected. `e2e-phase5b.js` (the enrollment-related suite +most exposed by this phase's one shared-code change — `enrollment.js`'s `resolveOrCreateStudent` +gained an optional, default-off parameter) rerun fresh: 32/32, including its own embedded +"Phase 5A student authentication still works unmodified" and "Phase 5A remote classroom still +works unmodified" checks. `e2e-phase5c.js` was not independently re-run: no code in +`notifications.js`/`students-auth.js` itself changed, only an additive, default-off parameter on +a function `notifications.js` doesn't call. + +## Frontend, migrations, content validation + +Frontend: `npm run build` clean, no frontend code touched this phase. Content validation: +0 errors, unchanged from Phase 6F's own numbers (this phase touched no course/module/activity +content at all). Migrations: additive-only, `CREATE TABLE IF NOT EXISTS`/`DROP CONSTRAINT IF +EXISTS` throughout, applied once via the existing `schema_migrations` tracking mechanism — no +destructive statement anywhere in `033`/`034`. + +## Documentation + +New: `ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md`, `OFFERING_AND_REGISTRATION_ +MODEL.md`, `STUDENT_GUARDIAN_DATA_MODEL.md`, `SCHOOL_AND_COMMUNITY_DELIVERY_ARCHITECTURE.md`, +this document. Updated: `WEBSITE_ENROLLMENT_INTEGRATION.md` (a documented future flow, current +contract unchanged). Reviewed, no change needed: `REMOTE_AND_SELF_PACED_ARCHITECTURE.md`, +`ENGINEERING_SOP.md`, `LAB_ARCHITECTURE.md`, `platform/README.md` (no new e2e-suite convention +beyond what its existing list format already documents — see commit history for the actual +`platform/README.md` addition covering `e2e-phase7.js`). + +## Known limitations + +- **No admin UI** for offerings/registrations — the API is complete and tested; the screens are + a well-specified follow-up, not a design question. +- **Guardian communications are not yet wired into `notifications.js`** — + `student_guardians.can_receive_communications`/`is_primary_contact` are recorded but nothing + currently reads them to route an actual email. +- **The website integration endpoint does not yet accept an `offering_slug`** — the future flow + is documented, not implemented; that decision (extend the existing endpoint vs. a parallel + one) was deliberately left open pending real website/checkout design. +- **`agreement_acceptances.accepted_by_type = 'student'`** is only meaningful post-conversion, + once a real `student_id` exists — an adult accepting at registration time (before their + account exists) currently must accept as `'guardian'`-style even when self-registering; a + future phase could add a `'self'` accepted-by-type distinct from `'guardian'` if that + distinction becomes operationally important. + +## Items requiring legal/business review before production use with real minors + +See `docs/STUDENT_GUARDIAN_DATA_MODEL.md`'s own list in full: the actual legal text of any +participation/media/liability agreement, jurisdiction-specific minor-consent requirements, +data-retention/deletion policy for guardian and registration records, and whether emergency- +contact fields need additional encryption at rest. None of these are resolved by this phase's +work — they are named so they are never silently assumed away. + +## Bugs found/fixed + +One (the seat-reservation-before-validation ordering bug above), found and fixed before any +commit via this phase's own smoke testing — not discovered by a later test run. + +## Recommended Phase 8 scope + +The admin UI for offerings/registrations (the one deliberately deferred piece of this phase's +own mission); wiring guardian communications into `notifications.js`; and, informed by real +business decisions, either extending `/api/integrations/enrollments` with an `offering_slug` +or designing the parallel endpoint the future commerce flow needs — plus whatever the business +decides about actually opening Winter Break Camp registration (which requires payment +processing, explicitly out of every phase's scope so far). diff --git a/docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md b/docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md new file mode 100644 index 0000000..671c9aa --- /dev/null +++ b/docs/ROOTNODE_ACADEMY_BUSINESS_AND_PRODUCT_ARCHITECTURE.md @@ -0,0 +1,143 @@ +# RootNode Academy — Business & Product Architecture + +Phase 7 Step 1 — the canonical bridge between RootNode's business strategy and its platform +architecture. This is where "what RootNode Academy sells and to whom" and "what the codebase +actually implements" are kept in one place, deliberately separated into BUILT NOW / PHASE 7 +BUILT / FUTURE so nobody has to guess which parts are real. + +## Course families + +INIT_, COMPILE_, ROOT_, and BOOTCAMP_ are **product positioning and technical-depth families**, +not a rigid age ladder and not a mandatory sequence: + +| Family | Typical audience (guidance, not a gate) | Status | +|---|---|---| +| INIT_ | ~ages 5-10 | Not yet authored (BUILT NOW: `programs.family` accepts it; PHASE 7 BUILT: nothing else; FUTURE: real curriculum) | +| COMPILE_ | ~ages 11-14 | Same as INIT_ | +| ROOT_ | ~ages 15+ through adult | **Fully authored, V1 pilot-ready** (Phase 6F) | +| BOOTCAMP_ | short/intensive seasonal or specialty offerings | BUILT NOW: `programs.family` accepts it; PHASE 7 BUILT: the offering/curriculum-package machinery a bootcamp needs; FUTURE: an actual bootcamp program row | + +**INIT → COMPILE → ROOT is explicitly NOT a mandatory path.** A student's access to any given +course depends on that course's own prerequisites (already real and server-enforced within +ROOT_ — see `docs/ROOT_V1_PREREQUISITE_MAP.md`), not on which family they've touched before. + +## COMPILE_ / ROOT_ overlap + +These two families intentionally cover overlapping technical territory at different depths — an +adult is not blocked from an electronics/robotics/IoT course merely because it's positioned +under COMPILE_, and a COMPILE_-age student is not blocked from later revisiting the same domain +at ROOT_ depth. Example: COMPILE_ might teach "ESP32 + a sensor + introductory MQTT"; ROOT_ might +teach "Linux + networking + a real broker/service + a containerized IoT workload + observability" +— same domain, different technical floor. **BUILT NOW**: nothing prevents this architecturally — +`programs`/`offerings` carry no family-based authorization check anywhere in the codebase. +**FUTURE**: the actual COMPILE_ electronics/robotics content itself (explicitly out of Phase 7's +scope). + +## Program vs. Course vs. Offering + +Three previously-conflated levels, now distinct: + +- **Program** (`programs` table, Phase 5B, unchanged): the educational product/path itself — + e.g. "ROOT Kubernetes Foundations." Carries `family` and a default `delivery_mode`. +- **Course** (`courses` table, Phase 6A, unchanged): a curricular unit inside a program — e.g. + "ROOT 02 Linux Administration & Networking." +- **Offering** (`offerings` table, **new this phase**): a specific purchasable/enrollable + *instance* of delivering a program (or a curriculum-package subset of one) — e.g. "Winter + Break Linux & Systems Camp — December 2026," "ROOT Foundations Cohort — Spring 2027," "ROOT + Self-Paced." A program can have many offerings across time; an offering always points at + exactly one program. + +A **workshop** and a **cohort** are both just `delivery_mode` values on an offering, not separate +tables — see "Delivery modes" below. A **curriculum package** (new this phase) is an optional, +named, versioned SUBSET reference into an existing program's courses, for when an offering (like +a camp) intentionally teaches only part of a program — see `docs/OFFERING_AND_REGISTRATION_MODEL.md`. + +## Age guidance, not a gate + +`offerings.recommended_min_age`/`recommended_max_age` and each course family's typical-audience +description are **display/marketing guidance only**. No API authorization check anywhere in this +codebase reads either value — enrollment and course access remain governed entirely by +prerequisites, entitlement (`enrollments.status`), and (new this phase) offering capacity/ +registration status. Verified explicitly in `platform/test/e2e-phase7.js`. + +## Delivery modes + +Curriculum and delivery are separate concepts. `offerings.delivery_mode` (new this phase) is a +single column supporting: `self_paced`, `cohort`, `hybrid`, `camp`, `workshop`, `school`, +`after_school`, `homeschool`, `community`, `one_to_one`. No curriculum content is duplicated per +mode — the same `program`/`courses`/`course_modules`/`module_activities` are read regardless of +which offering enrolled the student into them, exactly the same principle +`docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md` already established for self-paced vs. +instructor-led delivery of ROOT_ V1. + +## Organizations and locations + +**Organization** (new this phase): a business/delivery relationship — a school, library, +homeschool group, community organization, or workforce-development partner. **Not** SaaS +multi-tenancy: RootNode Academy remains one Academy, one database, one set of students/ +instructors; an organization is metadata an offering can optionally reference, not a data +partition. **Location** (new this phase): a physical or virtual delivery site, optionally tied +to an organization (a rented community-center room needs no partner org; "online" needs neither +an org nor a physical address). See `docs/SCHOOL_AND_COMMUNITY_DELIVERY_ARCHITECTURE.md`. + +## Guardians and minors + +**Guardian** (new this phase, `guardians`/`student_guardians`): a real, separate identity from a +student's own account — not a login/auth identity yet (no password, no session), just a +contact/relationship record. Replaces the Phase 5C `enrollments.metadata.contact_email` +workaround as the proper foundation, while leaving that workaround functioning unchanged for +backward compatibility with the live website integration. See +`docs/STUDENT_GUARDIAN_DATA_MODEL.md`. + +## Registration, enrollment, and agreements + +**Registration** (new this phase, `offering_registrations`): pre-enrollment interest/application +information for a specific offering — separate from `enrollments` (academic entitlement, +unchanged). A registration becomes a real enrollment only once `confirmed`, via +`convertRegistrationToEnrollment()`. **Agreement** (new this phase, +`agreement_definitions`/`agreement_acceptances`): a versioned ACCEPTANCE RECORD, never legal +text authored by this codebase. See `docs/OFFERING_AND_REGISTRATION_MODEL.md`. + +## Commerce boundary — unchanged, extended in documentation only + +The existing boundary (`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) stands: the website owns +marketing/pricing/checkout, RootNode's platform owns everything after a verified commerce event. +Phase 7 adds a documented FUTURE flow (website → offering selection → registration → checkout → +verified commerce event → registration confirmed → enrollment activated) without implementing +any payment code. **BUILT NOW**: the existing `/api/integrations/enrollments` endpoint, +unchanged. **PHASE 7 BUILT**: the registration/offering model that future flow will plug into. +**FUTURE**: an actual commerce-event webhook confirming a registration. + +## First commercial target: Winter Break Camp + +Per `docs/WINTER_BREAK_CAMP_CURRICULUM_OPTIONS.md`'s Option A (Linux & Systems Foundations, +ROOT 01-02, ~12.4 hours), Phase 7 models this as a real, if still DRAFT, offering +(`docs/OFFERING_AND_REGISTRATION_MODEL.md`'s worked example) — proving the platform CAN +represent and operate it, without inventing dates, venue, price, or seat count that aren't +already decided. + +## Future memberships/subscriptions and institutional delivery + +Recurring (self-paced subscriptions, after-school programs, school/community contracts) and +institutional (schools, libraries, workforce partners) revenue channels are **FUTURE** — the +`organizations`/`locations`/`offerings` model this phase builds is deliberately general enough +to represent them later without a schema redesign, but no billing/subscription code exists or is +planned this phase. + +## BUILT NOW / PHASE 7 BUILT / FUTURE — summary + +| Concept | Status | +|---|---| +| Programs, courses, ROOT_ V1 curriculum | BUILT NOW (Phases 1-6F) | +| `programs.family` accepting `bootcamp` | PHASE 7 BUILT (schema widening only) | +| Organizations, locations | PHASE 7 BUILT | +| Guardians, student-guardian relationships | PHASE 7 BUILT | +| Offerings, capacity, server-defined status transitions | PHASE 7 BUILT | +| Curriculum packages (subset references) | PHASE 7 BUILT | +| Registrations, registration lifecycle | PHASE 7 BUILT | +| Agreement definitions/acceptances (record-only, no legal text) | PHASE 7 BUILT | +| Admin routes for all of the above | PHASE 7 BUILT | +| Admin UI (Offerings/Registrations screens) | **NOT BUILT this phase** — see known limitations | +| Public registration flow, payments, subscriptions | FUTURE | +| INIT_/COMPILE_ curriculum | FUTURE | +| School/library SIS integrations, parent portal | FUTURE, explicitly out of scope | diff --git a/docs/SCHOOL_AND_COMMUNITY_DELIVERY_ARCHITECTURE.md b/docs/SCHOOL_AND_COMMUNITY_DELIVERY_ARCHITECTURE.md new file mode 100644 index 0000000..bf05d3f --- /dev/null +++ b/docs/SCHOOL_AND_COMMUNITY_DELIVERY_ARCHITECTURE.md @@ -0,0 +1,64 @@ +# School & Community Delivery Architecture + +Phase 7 Step 27 — how a future school, library, homeschool organization, or community partner +maps onto the platform's new organization/location/offering model. This is architecture +documentation for an asset-light delivery strategy — RootNode favors partner/temporary venues +over building permanent classrooms everywhere, at least initially. + +## The mapping + +``` +Organization (e.g. "Oxford Community Library") + └─ Location (e.g. "Library — Community Room B", or "Online") + └─ Offering (e.g. "After-School ROOT Foundations — Library Branch, Spring 2027") + ├─ program_id → an existing program (ROOT_ V1, or a future family) + ├─ curriculum_package_id → optionally, a subset (Step 20) + ├─ delivery_mode → 'after_school' / 'community' / 'school' / 'homeschool' + ├─ cohort_id → the real classroom/roster mechanism, unchanged + └─ instructor_offerings → who teaches it +``` + +A school, library, or homeschool organization is represented as ONE `organizations` row. It may +have multiple `locations` (a school with two campuses; a library system with several branches). +Each real delivery of curriculum at that organization is its own `offerings` row — this lets one +organization run several different offerings (different programs, different cohorts, different +semesters) without any of them being confused for each other, and without needing a new +mechanism for "an organization has many offerings" (it's the same one-to-many `organization_id` +foreign key `locations` and `offerings` already both carry). + +## What this is NOT + +**Not multi-tenancy.** No table in this schema is partitioned by `organization_id` the way a +true multi-tenant SaaS product would be — students, instructors, courses, and progress are not +scoped to an organization. An organization is a business/delivery *context* an offering can +optionally reference, nothing more. RootNode Academy remains one Academy with one shared +platform, one curriculum, and one set of students — a student who attends via a school +partnership is a normal RootNode student, indistinguishable in the data model from a self-paced +student, except for which offering(s) they registered through. + +## Multi-location operation + +Two offerings at two different locations coexist with no special isolation mechanism needed — +they're just two rows in `offerings` with different `location_id`s, each with its own cohort/ +roster/registrations. `platform/test/e2e-phase7.js` proves this concretely: two offerings, two +locations, verified to never leak state into each other. This is operational separation (two +independent business records), not a security/tenant-isolation claim. + +## What was explicitly NOT built this phase + +- **No school SIS integration** (no roster import/export, no grade-passback, no LMS + interoperability standard like OneRoster or LTI). +- **No Google Classroom integration.** +- **No organization-specific portal or branding** — every organization's students see the same + RootNode Academy platform; there is no white-labeling. +- **No organization-level admin role** — an organization contact does not get their own login or + visibility into the platform; all administration remains through RootNode's own + instructor/admin accounts, same as any other offering. + +## Future extension points (not built, but not blocked) + +If a future phase needs an organization contact to see their own offering's roster/progress +(e.g. a school liaison checking on their students), that's a new, scoped RBAC role — the +`organizations`/`offerings` model here doesn't need to change to support it, only the +authorization layer would need a new role definition, deliberately deferred rather than guessed +at now. diff --git a/docs/STUDENT_GUARDIAN_DATA_MODEL.md b/docs/STUDENT_GUARDIAN_DATA_MODEL.md new file mode 100644 index 0000000..f7e1373 --- /dev/null +++ b/docs/STUDENT_GUARDIAN_DATA_MODEL.md @@ -0,0 +1,79 @@ +# Student & Guardian Data Model + +Phase 7 Step 28 — why student and guardian identities are kept separate, what data-minimization +principles govern the new `guardians`/`student_guardians` tables, and what still needs +professional/legal review before any of this touches a real minor's data in production. + +## Why separate identities + +A student's own identity (`students` table — `email`/`login_identifier`, unchanged) is their +academic account. A guardian (`guardians`, new this phase) is a real, separate identity: a +parent with three enrolled children is **one** guardian row linked to three students via +`student_guardians`, not a guardian email copy-pasted into three student records. This mirrors +exactly the reasoning `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` already gave for keeping +`contact_email` off `students.email` — a contact address is not an academic identity, and now a +guardian is a real relationship, not a workaround field. + +## What this phase deliberately did NOT build + +- **No Parent Portal.** A guardian record has no login, no password, no session — it cannot + authenticate into anything yet. Building that is a real, separate future decision. +- **No child-activity surveillance.** Nothing in this schema or its routes lets a guardian (once + they could log in, in some future phase) see a student's Lab activity, scenario attempts, or + detailed learning analytics by default. A guardian relationship is a contact/authorization + record, not a monitoring grant. +- **No automatic broad access.** `student_guardians.can_receive_communications` and + `is_primary_contact` govern notification targeting only (a future concern — this phase writes + the rows but does not wire them into `notifications.js`'s resolveRecipient yet), never + authorization to student data. + +## Data minimization + +Only fields with a genuine, near-term operational purpose were added: `display_name`, `email`, +`phone`, and a free-text `notes` field for a guardian; `relationship` +(`parent`/`guardian`/`authorized_contact`/`emergency_contact`), `is_primary_contact`, and +`can_receive_communications` for the relationship itself. Registration-time fields +(`readiness_notes`, `accessibility_notes`, `emergency_contact_name`/`emergency_contact_phone`) +were chosen because a real camp/cohort genuinely needs them operationally — **no medical-record +functionality was built**, and `accessibility_notes` is a free-text operational note (e.g. "uses +a screen reader," "needs extra Lab time"), never a structured health/diagnosis field. + +## RBAC + +Guardian records and registration detail (readiness/accessibility/emergency-contact notes) are +admin/owner-only routes — a plain instructor's existing cohort-scoped access to a student's +academic progress is unchanged, but they do not automatically see guardian contact information +or registration notes through the new routes. See `admin-offerings-routes.js`'s own RBAC +comment and `platform/test/e2e-phase7.js`'s guardian-RBAC test. + +## Adult vs. minor enrollment + +An adult learner registers and is their own `registrant_email`/eventual student identity — no +guardian row is created or required. A minor's registration requires a `guardian_id` (enforced +by a database CHECK constraint on `offering_registrations`, not just application logic). Neither +path writes a guardian's email into `students.email` — a minor with no email of their own still +gets `login_identifier`-based identity (the same mechanism `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` +already established), preserving the identity semantics Phase 5A/5C set up. + +## Retention — flagged, not decided + +This phase does not implement a data-retention or deletion policy for guardian/registration +records. **Requires future legal/business review**: how long registration data (especially +accessibility/emergency-contact notes for a minor) should be retained after an offering +completes or a registration is cancelled, and what a guardian's/family's data-deletion request +process should look like. + +## Items explicitly requiring professional/legal review before production use with real minors + +- Whether the current field set (name/email/phone/relationship) is sufficient or whether + jurisdiction-specific requirements (e.g. COPPA-adjacent consent language, state-specific camp/ + minor-supervision regulations) require additional structured fields. +- The actual legal text of any participation agreement, media consent, or liability waiver — + `agreement_definitions.body_summary` is a plain-language placeholder for admin-UI display + only, never a substitute for attorney-reviewed legal language. +- Data retention/deletion policy for guardian and registration records. +- Whether `emergency_contact_name`/`emergency_contact_phone` need to be encrypted at rest beyond + Postgres's existing storage (no additional encryption was added this phase). + +None of the above is resolved by this phase's work — they are named here specifically so they +are not silently assumed away. diff --git a/docs/WEBSITE_ENROLLMENT_INTEGRATION.md b/docs/WEBSITE_ENROLLMENT_INTEGRATION.md index 63ea14c..fe19654 100644 --- a/docs/WEBSITE_ENROLLMENT_INTEGRATION.md +++ b/docs/WEBSITE_ENROLLMENT_INTEGRATION.md @@ -171,10 +171,37 @@ email — otherwise the enrollment is created but nobody is notified automatical instructor has to relay the activation link manually. `contact_email` is stored only against that specific enrollment, never copied into the student's own identity record. -There is no guardian/parent account system yet — the enrollment is created directly against the -student identity you provide. If you need to support "a parent manages multiple children's -enrollments" as a first-class concept later, talk to the RootNode team before building around a -workaround; the schema has room for it but it isn't implemented. +**As of Phase 7, a real guardian/parent record now exists** (`guardians`/`student_guardians` — +see `docs/STUDENT_GUARDIAN_DATA_MODEL.md`), but this endpoint's contract is UNCHANGED and does +not yet create or link one — `student.contact_email` still works exactly as described above, +stored only on the enrollment. If you need "a parent manages multiple children's enrollments" as +a first-class concept in this endpoint, talk to the RootNode team before building around a +workaround; the schema now has the real tables for it, they're just not wired into this +endpoint yet. + +## Future flow: offerings and registrations (Phase 7, not yet live on this endpoint) + +RootNode Academy is now (rootnodeacademy.com, live separately from this repository) building +toward representing specific purchasable deliveries — camps, cohorts, self-paced access — as +first-class **offerings**, with a **registration** step before final enrollment (see +`docs/OFFERING_AND_REGISTRATION_MODEL.md`). The eventual intended flow: + +``` +Website: learner selects an offering (e.g. "Winter Break Linux & Systems Camp") + -> Website collects registration info (student/guardian, readiness, agreements) + -> Website checkout/payment (future — not built yet) + -> Verified commerce event -> RootNode confirms the registration + -> RootNode activates the enrollment (reusing this exact endpoint's machinery) + -> Student receives their existing activation email, unchanged + -> Student/camp access begins +``` + +**This is documentation of an intended future shape, not a live contract change.** The endpoint +below is unaffected — it continues to work exactly as documented, program-slug-based, with no +concept of an offering. A future phase will either extend this endpoint with an optional +`offering_slug` field or add a parallel endpoint once the website's actual checkout flow is +designed; that decision has not been made. Idempotency (`event_id`) will remain the mechanism +either way. ## What the student experiences after you call this (as of Phase 5C) diff --git a/platform/README.md b/platform/README.md index c3be3e0..6992021 100644 --- a/platform/README.md +++ b/platform/README.md @@ -106,6 +106,12 @@ further instructor accounts from the Instructors tab). prerequisite graph (no cycles, no missing nodes), the strengthened Capstone gate (ROOT 09 AND ROOT 12 both required) in both directions, the new orientation module, and an explicit check that no age/grade gate exists anywhere in enrollment. +- `test/e2e-phase7.js` — organizations/locations, guardians and student-guardian relationships, + curriculum packages (reference-only, never a content clone), offerings (capacity, server- + defined status transitions), the registration lifecycle through agreement acceptance and + conversion to a real enrollment, registration/guardian RBAC, explicit age-guidance-is-never- + an-authorization-check verification, and confirmation that existing ROOT_ V1/self-paced/ + classroom flows are unaffected. `infra/scripts/drills/` also holds real-Lab dress rehearsals per phase (Phase 6D/6E foundations, Phase 6F's multi-student pilot and full end-to-end V1 journey) — see each script's own header