# Phase 5B — Self-Paced Learning & Enrollment Foundation **Status: PHASE 5B COMPLETE. SELF-PACED LEARNING FOUNDATION COMPLETE. WEBSITE ENROLLMENT BOUNDARY READY.** Phase 5A gave students real identity and instructors a remote classroom. Phase 5B's job was the last piece needed for an authenticated student to be enrolled in a RootNode Academy program and complete it entirely on their own — plus the clean, provider-neutral boundary a *separate* public website/commerce system will call to create those enrollments later. This phase does **not** build that website or any payment processing — see §12. ## 1. The RootNode program ladder `init_` (ages ~5-10, robotics/block coding), `compile_` (ages ~11-14, ESP32/MicroPython/IoT), `root_` (ages ~15-18+, Linux/containers/Kubernetes/DevOps). The existing KubeKosh-derived curriculum belongs to `root_`. This phase does not write init_/compile_ curriculum content — it makes the platform capable of holding those programs later without another schema change (the `programs.family` enum already accepts `init`/`compile`/`root`). ## 2. Programs — a first-class object, not curriculum JSON `programs` (migration `019_programs.sql`) is the commercial/academic unit a student enrolls in. It is deliberately **not** the curriculum file itself — `programs.curriculum_path` just points at one (today, always `rootnode-path`, the only curriculum that exists). `program_id` is the slug primary key, matching `assessment_definitions`/`cohorts`' existing convention rather than a separate id+slug pair. `family` (`init`/`compile`/`root`) and `delivery_mode` (`self_paced`/`cohort`/`hybrid`) are both real CHECK-constrained enums, not free text — small, stable vocabularies, matching how this codebase already treats `assessment_definitions.type` and `hint_policy`. The phase seeds exactly one program: `root-kubernetes-foundations`, wrapping the existing RootNode/Kubernetes curriculum and the existing `rootnode-capstone` as its completion criterion — no new curriculum content invented. `programs.version` bumps automatically whenever an edit changes a field an enrollment snapshots (`curriculum_path`, `delivery_mode`) — same versioning principle `assessment_definitions` already established, so editing a program later never silently changes what an already-enrolled student is being held to. ## 3. Enrollments — real academic access, separate from cohorts `enrollments` (migration `020_enrollments.sql`) represents a student's access to a program: `pending → active → completed`, or `pending`/`active → cancelled`. All transitions are server-side only (`enrollment.js`) — nothing accepts a client-supplied status. `expired` is **derived**, not stored: `deriveEnrollmentAccess(enrollment, now)` is a pure function (matching Phase 5A's `classroom.js` `derivePresence` pattern) that computes it from `access_ends_at` at read time, so there's no background sweep and nothing to go stale. A self-paced enrollment has **no cohort at all** — `cohort_enrollments` (Phase 1) is completely untouched. A student can hold a cohort enrollment, a self-paced enrollment, and multiple historical enrollments simultaneously without any of those concepts colliding; re-enrollment after completion/cancellation always creates a new row rather than reviving a historical one (a partial unique index enforces at most one `pending`/`active` enrollment per student+program at a time — DB-level, not app-level "check then insert"). ## 4. Curriculum assignment — the schema Phase 4.1 flagged, now wired Phase 4.1's `curriculum_assignments` table (schema-ready since Phase 2B, never read by anything) is **not** reused as-is — it models cohort/student curriculum assignment independent of any commercial concept, which is a different problem than "this enrollment in this program uses this curriculum." Instead, `enrollments.curriculum_path` is itself the snapshot: set from `programs.curriculum_path` at enrollment time, so a later edit to the program never changes what an existing enrollment means. `curriculum_assignments` remains exactly as Phase 4.1 left it — still available for a future direct per-student assignment need outside the program/enrollment model, not duplicated or removed. The resolution chain is real: `enrollment.student_id → enrollment.program_id → enrollment.curriculum_path → curriculum.loadCurriculumPath() → stages → scenarios` (`program-status.js`'s `buildProgramStatus`). Only one curriculum path exists today, so this always resolves to `rootnode-path` — the extension point for a second path is `enrollments.curriculum_path` itself, already snapshotted per-enrollment. ## 5. Self-paced dashboard and resume — reused, not reinvented `GET /api/student/programs` returns every enrollment a student holds (never a public catalog — no public course browsing exists), each annotated with real access state, progress, and a deterministic next activity. The heavy lifting is **entirely existing Phase 3B machinery**: `curriculum-rules.js`'s `computeCurriculumState` already returns `current_stage` and each stage's `next_scenario` — `program-status.js` just wraps that per-enrollment. "Continue Learning" resume is not a guess based on the last page rendered; it's the same rule-driven current-stage/next-scenario computation the My Learning tab has used since Phase 3B, now scoped per program. Destroying a Lab never touches this — `progress`/`scenario_attempts` live in Postgres, completely independent of any container's lifecycle (verified for real, not just asserted — see §9). ## 6. Entitlement — enforced server-side, not by hiding a button `POST /api/student/programs/:enrollmentId/launch` is the only self-paced entry point into a Lab. It resolves the enrollment (404 if it isn't the caller's own), calls `deriveEnrollmentAccess`, and 403s with a `reason` (`pending_activation` / `not_yet_started` / `access_expired` / `cancelled`) if `can_launch` is false — **before** ever touching the Provisioner. A pending, future-dated, expired, or cancelled enrollment cannot provision a Lab no matter what the browser sends. The existing `POST /api/environment/launch` (cohort/pilot path) is completely unchanged — this is a new, additive gate in front of the exact same disposable-Lab sequence (`environment-view.js`, extracted from `server.js` so both routes share one implementation), not a second kind of Lab. ## 7. Completion — server-derived and idempotent `enrollment.maybeCompleteEnrollment` checks, for every `active` enrollment whose program has a `completion_assessment_id`, whether a matching `capstone_completed` achievement already exists (the achievement `recordAssessmentAchievement` already writes when a capstone session finalizes — Phase 3B, unmodified). If so, it transitions the enrollment to `completed` — an `UPDATE ... WHERE status = 'active'`, so calling it again once already completed is a guaranteed no-op, not a duplicate transition or an error. This runs opportunistically on every program-status read (`program-status.js`), which means completion is detected on the next read after the qualifying event, not pushed instantly — acceptable latency for a status field, and it means there is no "mark complete" button anywhere in the frontend to accidentally trust. ## 8. Pacing and availability `starts_at`/`access_ends_at` are the entire pacing model this phase builds — no drip scheduling, no per-lesson unlock dates. `starts_at` in the future: the enrollment is visible (`effective_status: 'active'`) but `can_launch: false`, reason `not_yet_started`. `access_ends_at` in the past: `effective_status: 'expired'`, prior progress/achievements remain fully visible, `can_launch: false`. Both are enforced in `deriveEnrollmentAccess`, called from the same server-side gate every launch goes through — never a frontend-only check. ## 9. Real Lab-persistence proof `platform/test/e2e-phase5b.js` and `infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js` both drive this against a real stack: launch a Lab, complete a scenario, destroy the environment via the admin API, relaunch, confirm identical progress and resume target; separately, kill the Lab container directly (bypassing the Provisioner, exactly the idle reaper's failure mode from the Provisioner's point of view) and restart the Provisioner to run its real `reconcileOnStartup` reconciliation (the same technique `infra/scripts/drills/ test-lab-host-loss.js` established in Phase 4.1) before relaunching — progress is identical both times. ## 10. Instructor/admin management `/admin` → Programs: list/create/edit/activate-deactivate (admin/owner only, matching `assessment_definitions`' authoring convention — every role can view), per-program enrollment list and manual-enroll form, per-enrollment activate/cancel. The existing student detail view gained an "Enrolled programs" card (status, delivery mode, progress, last activity). **RBAC** (`rbac.js`'s new `requireEnrollmentAccess`): owner/admin have global management, as everywhere else. A plain instructor may view/manage an enrollment if they created it (`enrollments.created_by`) **or** the student shares one of their cohorts — never blanket visibility into every self-paced student, since a self-paced enrollment can have no cohort relationship at all. Enrollment *creation* stays admin/owner only, matching the existing roster-creation precedent (`POST /api/admin/students` is the same restriction). ## 11. The website/commerce integration boundary `POST /api/integrations/enrollments` — completely separate from student/instructor session auth (no cookie is ever read), authenticated by a pre-shared secret header (`X-RootNode-Integration-Key`), the exact same trust-boundary pattern this codebase already uses for Gateway↔Provisioner (`X-Provisioner-Key`/`PROVISIONER_SHARED_SECRET`), not a new scheme. Full contract for the separate website team: `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`. Idempotent by construction: a unique index on `enrollments.external_event_id`, not an app-level "check then insert" — proven under real concurrency (three simultaneous identical requests produce exactly one enrollment, one `created: true`, two `created: false`) in both the permanent suite and manually during development. Student matching is create-or-match by email **or** login_identifier (§13's minor/no-email requirement) — reuses Phase 5A's exact `students-auth.js` credential-issuing path, not a second account-creation mechanism, and this is a trusted server-to-server caller (the website's own verified-payment backend), not a public signup form. ## 12. What was deliberately not built (§22) Stripe/payment processing, checkout UI, the public website itself, public self-registration, a Parent Portal or full guardian accounts, email delivery infrastructure, certificates, complex drip scheduling, and full init_/compile_ curriculum content. Each is a plausible "while we're in here" addition; each was left alone on purpose. Nothing built this phase blocks any of them — `enrollments.external_reference`/`metadata` and the create-or-match student resolution are the deliberate extension points for a future guardian-purchases-for-minor flow, without inventing a fake guardian record now. ## 13. A real bug found and fixed `POST /api/progress/record` predated the `wrap()` async-error-handling convention (it's a Phase 1/2A route, never updated). An unexpected internal error there — including Postgres rejecting a malformed `environment_id` — became an unhandled promise rejection that **crashed the entire Platform API process**, not just that one request. Found by this phase's own test suite. Fixed: the handler is now wrapped like every route added since Phase 3B, plus explicit UUID-shape validation so a bad `environment_id` is a clean 400 and never reaches Postgres as a type error at all. Regression-tested in `e2e-phase5b.js`. ## 14. Testing - `platform/test/e2e-phase5b.js` (32 checks): program seed, entitlement gating (pending/future/expired/cancelled), cross-student isolation, real Lab launch/destroy/reaper/ recreate persistence, server-derived idempotent completion, the full integration boundary (auth, idempotency, concurrency, unknown/inactive program rejection, minor/no-email path, no card data), RBAC scoping, Phase 5A regression smoke, and a real Platform API restart. - `infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js` (15 checks): one continuous realistic story — activation, a simulated website enrollment event, real Lab work, Lab destroy, a new session later, Continue Learning, a fresh Lab, full capstone completion, program moves to Completed, and a second uninvolved student proven unable to reach any of it. - Both against a live `docker-compose.dev.yml` stack, real HTTP, real k3s Lab boots. - Migration safety proven explicitly: a fresh Postgres taken through Phase 1-5A's migrations only, seeded with realistic pre-5B data (credentialed student, cohort membership, progress, scenario attempts, achievements, classroom history), then 5B's migrations applied — every prior record intact and correctly associated with the same `student_id`; a new enrollment correctly links to that same pre-existing student; running the migration set a second time applies nothing.