Files
rootnode-academy/docs/PHASE5B_SELF_PACED_ENROLLMENT.md
T
ced4568 1c08a30c7f docs(phase5b): self-paced enrollment documentation + website integration contract
- PHASE5B_SELF_PACED_ENROLLMENT.md: the full design doc.
- RUNBOOK_SELF_PACED_ENROLLMENT.md: operational runbook for /admin -> Programs.
- WEBSITE_ENROLLMENT_INTEGRATION.md: the complete contract for the separate website team —
  endpoint, auth, request/response schema, idempotency/retry rules, student matching, minor/
  no-email behavior, error responses, what not to send, local testing, production-secret
  handling. Written to be usable without reading the Platform API source.
- REMOTE_AND_SELF_PACED_ARCHITECTURE.md: closes the self-registration/curriculum-assignment/
  delivery-mode/enrollment-model gaps this doc previously flagged as Phase 5+ scope.
- ENGINEERING_SOP.md: new §15. LAB_ARCHITECTURE.md, PRODUCTION_VALIDATION_CHECKLIST.md,
  README.md, platform/README.md: cross-references and a Phase 5B production-validation section.
2026-08-25 12:10:13 -05:00

196 lines
13 KiB
Markdown

# 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.