- 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.
13 KiB
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.ymlstack, 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.