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.
This commit is contained in:
2026-08-25 12:10:13 -05:00
parent 23f881884d
commit 1c08a30c7f
9 changed files with 558 additions and 28 deletions
+9 -1
View File
@@ -163,6 +163,14 @@ its core:
classes from `/admin` (presence, Lab/learning status, help-request queue, announcements,
guidance-only current-activity) — RootNode owns identity/coordination, an external service
(Meet/Zoom/Teams/etc.) owns the actual video/voice.
- Students can now be enrolled in a self-paced program (as of Phase 5B) — "My Programs" on the
student dashboard, entitlement enforced server-side (pending/future/expired/cancelled
enrollments cannot launch a Lab no matter what the browser sends), completion server-derived
from the existing capstone rules. `/admin` gained a Programs area (program authoring, manual
enrollment, activate/cancel). A separate, provider-neutral `POST /api/integrations/enrollments`
boundary — server-to-server only, no browser session involved — is what the future public
`rootnodeacademy.com` website/checkout will call to create these enrollments; see
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
**Never publish the raw privileged container** — front it with isolation (that's what the
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
@@ -200,7 +208,7 @@ Key properties of the platform (not this image):
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`,
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`.
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`.
The production infrastructure package (Proxmox/Ansible/Terraform/firewall/monitoring/backup/CI)
that runs all of this on real hardware lives in `infra/` — see
+31
View File
@@ -357,3 +357,34 @@ Full design: `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, plus
students and confirming zero orphaned data) — both against a live `docker-compose.dev.yml`
stack, not mocks. The full pre-existing regression suite was re-run given how much shared code
this phase touched (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`).
## 15. Self-Paced Learning & Enrollment Foundation (Phase 5B)
Full design: `docs/PHASE5B_SELF_PACED_ENROLLMENT.md`, plus
`docs/RUNBOOK_SELF_PACED_ENROLLMENT.md` and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md`.
- **What it is:** `programs` (a first-class learning product, distinct from curriculum JSON —
`curriculum_path` is just what it points at) and `enrollments` (a student's academic access to
a program, independent of cohort membership — a self-paced enrollment has no cohort at all).
Server-enforced entitlement (`enrollment.deriveEnrollmentAccess`, mirroring `classroom.js`'s
derived-not-stored `derivePresence` pattern from Phase 5A) gates a new
`POST /api/student/programs/:id/launch` in front of the exact same disposable-Lab sequence
`/api/environment/launch` already used (extracted into `environment-view.js` so both share one
implementation) — pending/future/expired/cancelled enrollments cannot launch, server-side, not
just hidden in the UI. Completion is entirely server-derived from the existing capstone
achievement mechanism (Phase 3B, unmodified) and idempotent. A new, separate
`POST /api/integrations/enrollments` boundary (its own pre-shared-secret auth, no session
cookie involved — same trust-boundary pattern as Gateway↔Provisioner's
`PROVISIONER_SHARED_SECRET`) is what the *separate* public website will eventually call;
idempotent via a DB unique constraint on the caller's own event id, proven safe under real
concurrent duplicate requests, not just sequential retries.
- **A real bug found and fixed:** `POST /api/progress/record` predated the `wrap()`
async-error-handling convention — an unexpected internal error there (a malformed
`environment_id`) crashed the entire Platform API process, not just that one request. Fixed
(wrapped + input-validated) and regression-tested.
- **Tested, not just designed:** `platform/test/e2e-phase5b.js` (32 checks) and
`infra/scripts/drills/test-phase5b-selfpaced-rehearsal.js` (15 checks) — both against a live
stack, including real Lab destroy/reaper-reconciliation/recreate persistence proofs, a full
real capstone completion, integration-endpoint concurrency, and a real Platform API restart.
Migration safety proven explicitly against a fresh Postgres seeded with realistic pre-5B data
(see that doc §14).
+12
View File
@@ -507,3 +507,15 @@ any of it — the Provisioner, the `environments` state machine, the Lab Host Ro
Lab lifecycle, and the Platform/Lab-Host VM boundary are all untouched. The classroom dashboard
reads Lab status the exact same way every other admin view does (`provisioner.listForStudent`,
the same state enum) rather than inventing a second one.
## Phase 5B: still reused, not modified — a gate in front, not a second Lab
Phase 5B (`docs/PHASE5B_SELF_PACED_ENROLLMENT.md`) added self-paced programs/enrollment on top
of the exact same disposable Lab this document describes — again without changing the
Provisioner, `environments`, the reaper, or the Lab Host Router at all. A self-paced launch
(`POST /api/student/programs/:id/launch`) is an entitlement check followed by calling the
identical launch sequence `/api/environment/launch` always has — verified for real by killing a
Lab container out-of-band and restarting the Provisioner to trigger its actual
`reconcileOnStartup` reconciliation path (the same technique Phase 4.1's
`test-lab-host-loss.js` established), then confirming a self-paced student's academic progress
is completely unaffected and a fresh Lab boots normally.
+195
View File
@@ -0,0 +1,195 @@
# 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.
+15
View File
@@ -67,5 +67,20 @@ can prove in advance.
- [ ] `docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`'s steps
still make sense walked against the real admin portal
## Self-paced enrollment & website integration (Phase 5B — see docs/PHASE5B_SELF_PACED_ENROLLMENT.md)
- [ ] `INTEGRATION_SHARED_SECRET` is set to a real, unique-to-production secret (not the dev
value) and is stored only in the Platform API's own server-side environment — never
committed, never in any browser-reachable config
- [ ] The website team's production integration key was delivered out of band (not plaintext
email/chat) and is confirmed working with one real, low-stakes test enrollment before go-live
- [ ] A deliberate `POST /api/integrations/enrollments` call missing/with a wrong key returns a
clean `401` against the real production endpoint, not a 500 or an information leak
- [ ] `GET /metrics`'s `rootnode_enrollments_total`/`rootnode_program_completions_total` labels
contain only status/delivery_mode/program_id values — confirm no student_id, email, or
external_reference ever appears in a real production scrape
- [ ] `docs/RUNBOOK_SELF_PACED_ENROLLMENT.md`'s steps still make sense walked against the real
admin portal, and `docs/WEBSITE_ENROLLMENT_INTEGRATION.md` is what the website team
actually integrated against (update it if reality drifted during that integration)
Only once every box above is checked against the real server does the deployment graduate from
"infrastructure package built" to "actually in production."
+28 -21
View File
@@ -45,11 +45,12 @@ this flow specifically — see the Phase 4.1 report for the full run results.
## What's genuinely missing for a real self-paced product (Phase 5, not built here)
1. **No self-registration / public enrollment.** A student row is created only by an instructor
(`POST /api/admin/students`, admin/owner RBAC-gated). There is no public "sign up" surface.
This is almost certainly the right call to leave alone for now — it's explicitly a Phase 5+
product/business decision (course commerce, is it free, who's allowed to enroll), not an
infrastructure gap.
1. **No self-registration / public enrollment surface.** Still true, still deliberate — Phase 5B
built the boundary a *separate* public website will call
(`POST /api/integrations/enrollments`, see `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §11 and
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md`) rather than a public sign-up form here. A student
row can now be created automatically by a trusted server-to-server enrollment event, but there
is still no browser-facing "sign up" page anywhere in this codebase — that's the website's job.
2. ~~**No real student authentication.**~~ **Closed in Phase 5A.** Students now have real
credentials (email+password or login-identifier+password, `password_hash`/`scrypt` reusing
@@ -59,25 +60,31 @@ this flow specifically — see the Phase 4.1 report for the full run results.
/api/login` still works for not-yet-converted pilot rows only; it's rejected (409) the moment
an account has real credentials or a pending activation token.
3. **`curriculum_assignments.student_id` is schema-ready but unwired.** The table has supported
a direct per-student curriculum assignment (independent of any cohort) since Phase 2B
(`CONSTRAINT curriculum_assignment_target CHECK (cohort_id IS NOT NULL OR student_id IS NOT
NULL)`) — but nothing in the application ever reads or writes that column today; there's only
one curriculum path, so nothing has needed to differentiate yet. Not a gap that blocks
self-paced delivery now (every student already sees the one path with no gate), but the
moment a second curriculum path exists, this is where per-individual assignment lands — the
schema was designed for exactly this and doesn't need a migration to support it.
3. ~~**`curriculum_assignments.student_id` is schema-ready but unwired.**~~ **Addressed
differently in Phase 5B.** `curriculum_assignments` itself remains unwired, unchanged — but
the real problem it was meant to eventually solve (which curriculum a given student sees) is
now solved at the enrollment layer instead: `enrollments.curriculum_path` snapshots
`programs.curriculum_path` per enrollment. `curriculum_assignments` is still available for a
future direct per-student assignment need outside the program/enrollment model — it wasn't
removed, just not the mechanism Phase 5B ended up using. See
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §4.
4. **No delivery-mode concept anywhere.** In-person, remote, and self-paced sessions aren't
distinguished in any data model today, and nothing currently needs them to be — capacity
planning, the reaper, and academic records all behave identically regardless of how a student
is attending. Worth being aware this is unmodeled, not worth building a taxonomy for before a
real product reason exists (YAGNI).
4. ~~**No delivery-mode concept anywhere.**~~ **Closed in Phase 5B.**
`programs.delivery_mode`/`enrollments.delivery_mode` (`self_paced`/`cohort`/`hybrid`) is now
a real, snapshotted field. Capacity planning, the reaper, and Lab lifecycle mechanics still
behave identically regardless of delivery mode — this only affects how enrollment/entitlement
is modeled, not infrastructure.
5. **No self-paced enrollment/entitlement model.** ~~Closed in Phase 5B~~ — see
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` for `programs`/`enrollments`, server-enforced
entitlement (pending/future/expired/cancelled), server-derived completion, and the resulting
self-paced student dashboard.
## What Phase 4.1 deliberately did NOT do here
No signup flow, no student password/email auth implementation, no per-student curriculum
assignment route, no payments, no public course catalog, no held/premium scenario content. All
explicit non-goals per this phase's authorization. Student authentication (item 2, above) was
closed in Phase 5A; the rest remain open, unmodeled by design (YAGNI) until a real product
reason exists.
explicit non-goals per this phase's authorization. Student authentication (Phase 5A) and the
self-paced enrollment/entitlement model (Phase 5B) have both since been built; a public
self-registration *page*, real payment processing, and a Parent/Guardian portal remain
deliberately out of scope — see `docs/PHASE5B_SELF_PACED_ENROLLMENT.md` §12.
+63
View File
@@ -0,0 +1,63 @@
# Runbook: Self-Paced Programs & Enrollment
Covers managing programs and enrollments from `/admin` → Programs. See
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` for the underlying design and
`docs/WEBSITE_ENROLLMENT_INTEGRATION.md` for the separate website-driven path.
**Create a program** (Programs → Create program, admin/owner only):
Program ID is a slug (e.g. `root-linux-fundamentals`) and can't be changed later. Pick a
family (`init_`/`compile_`/`root_`) and delivery mode. New programs default to
`curriculum_path: rootnode-path` — the only curriculum content that exists today. A program for
`init_`/`compile_` can be created now as a placeholder and wired to real content later without a
schema change; it just won't have real lessons behind it yet.
**Activate/deactivate a program** (Programs → \[program\] → Activate/Deactivate):
An inactive program can't accept new enrollments (admin-manual or via the website integration
endpoint) but existing enrollments in it are untouched — students already enrolled keep their
access exactly as it was.
**Manually enroll a student** (Programs → \[program\] → Enroll a student):
Enter the student's ID (find it on the Students tab). Choose `pending` (the default — the
student sees the enrollment but can't start until you Activate it) or `active` (skip that step).
Optional start/end dates enforce access windows server-side — a future start date hides Lab
access until then; an end date cuts off new Lab launches after it while leaving prior progress
visible.
**Activate a pending enrollment** (Programs → \[program\] → Enrollments table → Activate):
Only needed if you enrolled as `pending`. This is the moment a student actually gains entry.
**Cancel an enrollment** (Programs → \[program\] → Enrollments table → Cancel, or Enrollments
list on a student's own page):
Immediately blocks new Lab launches. Does not touch the student's progress/achievements/attempt
history — those are permanent regardless of enrollment status. Cancelling is final for that
enrollment; if the student should have access again later, enroll them again (a new enrollment
row) rather than trying to "reactivate" a cancelled one.
**Check a student's enrollment history** (Students → \[student\] → "Enrolled programs" card):
Shows every program the student has ever been enrolled in — pending, active, completed,
cancelled, expired — with progress and last activity. A plain instructor sees this scoped to
enrollments they created or that belong to a student sharing one of their cohorts; owner/admin
see everything.
**A student says "Continue Learning" isn't working / shows the wrong thing:**
1. Check their enrollment status on the Programs or Student page. `pending` → activate it.
`expired`/`cancelled` → that's working as intended; enroll them again if they should continue.
2. If `active` and still stuck, check `/api/admin/students/:id/environment` for a stuck Lab —
same troubleshooting as any other Lab issue (`docs/RUNBOOK_STUCK_LAB_CLEANUP.md`). Destroying
the Lab never loses their program progress; a fresh launch resumes from the same place.
**A student's completion didn't register:**
Completion is detected the next time their program status is *read* (their dashboard, or an
admin viewing their enrollment) after they finish the capstone — not pushed instantly. Refresh
the page or reopen their student detail in `/admin`; if it still doesn't show `completed` after
that, check `GET /api/achievements` for that student for a `capstone_completed` record matching
the program's configured capstone — if that's missing, the capstone itself wasn't actually
finished, not an enrollment bug.
+197
View File
@@ -0,0 +1,197 @@
# Website → RootNode Academy Enrollment Integration
Audience: whoever builds `rootnodeacademy.com` (marketing, pricing, checkout) and needs to turn
a successful purchase/signup into a real RootNode Academy student enrollment. This document is
meant to be enough on its own — you should not need to read the Platform API's source to
integrate against it.
## Architecture
```
rootnodeacademy.com (marketing, pricing, checkout — built separately, not in this repo)
|
| your backend verifies payment/signup with your payment provider
v
your backend calls the RootNode Enrollment API (below) — server-to-server only
v
RootNode Academy Platform API
creates/matches a student account
creates or reuses (idempotently) an enrollment
v
the student logs in at the RootNode Academy platform and sees their program
```
**RootNode never talks to your payment provider, and your payment provider never talks to
RootNode.** Your backend is the only thing that calls this endpoint, after it has already
confirmed payment (or whatever your enrollment trigger is) on its own. This endpoint knows
nothing about Stripe, PayPal, or any other provider's object model — it only understands a
small, generic enrollment event described below.
## The endpoint
```
POST /api/integrations/enrollments
```
Reachable at the RootNode Academy platform's public origin (the same host your students'
browsers already use), via HTTPS in any real deployment. This is the **only** endpoint your
backend needs.
## Authentication
A pre-shared secret, sent as a header:
```
X-RootNode-Integration-Key: <the secret>
```
- This is **not** a student or admin login. It has nothing to do with cookies, sessions, or
browser auth of any kind.
- **The secret must live only in your backend's server-side configuration.** It must never be
embedded in any browser-served JavaScript, mobile app bundle, or client-side config — anyone
who obtains it can create enrollments.
- A request with a missing or wrong key gets `401 { "error": "unauthorized" }` and nothing else
happens — no student is looked up, nothing is created.
- RootNode will provide you with the production secret out of band (not over email/chat in
plaintext — ask the RootNode team for the current secure-sharing process). Rotate it by
asking RootNode to issue a new one; there is no self-service rotation yet.
## Request
```json
{
"event_id": "order_9f8e7d6c5b4a",
"source": "website",
"program_slug": "root-kubernetes-foundations",
"status": "paid",
"student": {
"email": "[email protected]",
"login_identifier": null,
"display_name": "Jamie Rivera"
}
}
```
| Field | Required | Notes |
|---|---|---|
| `event_id` | **yes** | A string, unique per real-world event, from *your* system (an order ID, a payment intent ID — anything stable). This is the idempotency key — see below. Not a RootNode-generated value. |
| `source` | no | Free text, e.g. `"website"`. Recorded for audit only, defaults to `"integration"` if omitted. |
| `program_slug` | **yes** | The RootNode program's id, e.g. `"root-kubernetes-foundations"`. Ask RootNode for the current list — there is no public program-catalog endpoint. |
| `status` | **yes** | One of: `"paid"`, `"active"`, `"confirmed"` (all mean "grant access now") or `"pending"`, `"awaiting_payment"` (means "record the enrollment but don't grant access yet"). This is a small, fixed, provider-neutral vocabulary — translate whatever your payment provider says into one of these five values before calling. Anything else is rejected. |
| `student.email` | one of email/login_identifier required | The student's own email, if they have one. |
| `student.login_identifier` | one of email/login_identifier required | Use this instead of/alongside email for a student who doesn't have their own email address (a minor enrolled by a parent/guardian — see below). Any stable, unique string you control — e.g. an internal user ID from your own system works well. |
| `student.display_name` | no | Shown in the RootNode instructor portal and, once the student logs in, to themselves. Falls back to the email or login_identifier if omitted. |
### What you must NOT send
- No card numbers, CVVs, bank details, or any other payment-card data. This endpoint has no
field for it and never will — RootNode never touches payment-card data at all.
- No raw provider webhook payloads (Stripe event objects, PayPal IPN bodies, etc.) — translate
to the shape above first. This endpoint is deliberately provider-neutral.
- No password. RootNode issues its own credential/activation flow (see "What the student
experiences" below) — you cannot set a student's password through this endpoint, and should
never try to invent one.
## Response
**Success — a new enrollment was created** (`201`):
```json
{ "enrollment_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "student_id": "student-042", "program_id": "root-kubernetes-foundations", "status": "active", "created": true }
```
**Success — this exact `event_id` was already processed** (`200`, idempotent replay — see below):
```json
{ "enrollment_id": "6dd6d898-f895-477f-97d8-1e5ce89c27cd", "student_id": "student-042", "program_id": "root-kubernetes-foundations", "status": "active", "created": false }
```
**Errors:**
| Status | Body `error` | Meaning |
|---|---|---|
| `401` | `unauthorized` | Missing/wrong `X-RootNode-Integration-Key`. |
| `400` | `event_id is required` | — |
| `400` | `program_slug is required` | — |
| `400` | `status must be one of: paid, active, confirmed, pending, awaiting_payment` | — |
| `400` | `student.email or student.login_identifier is required` | — |
| `404` | `PROGRAM_NOT_FOUND` | `program_slug` doesn't exist. |
| `409` | `PROGRAM_INACTIVE` | The program exists but isn't currently open for enrollment. |
| `500` | `internal_error` | Something unexpected broke server-side. Safe to retry (see below). |
## Idempotency and retries
**`event_id` is the whole mechanism.** Send the exact same `event_id` again (your payment
provider retried a webhook, your own backend retried a timed-out request, anything) and you get
back the **same enrollment**, `created: false`, `200` instead of `201` — never a duplicate. This
is enforced at the database level (a unique constraint), not just "best effort," so it's safe
under concurrent/parallel retries too, not just sequential ones.
**Retry policy:** on a `5xx` or a network error/timeout, retry with the *same* `event_id`
(standard exponential backoff is fine — a few attempts over a couple of minutes is plenty). On a
`4xx`, do not blindly retry — fix the request (see the error table above) or, for `401`, check
your key.
**Never generate a new `event_id` for a retry of the same real-world event.** Doing so would
create a second enrollment. Reserve a fresh `event_id` only for a genuinely new enrollment event
(e.g. a renewal, a second purchase).
## Student matching rules
RootNode looks for an existing student by `email` (case-insensitive) or `login_identifier`
(exact match), in that priority. If found, that student is enrolled. If not found, a new student
account is created automatically — this is a trusted server-to-server call from your backend,
equivalent to an instructor adding a student through the admin portal, not a public signup form.
## Minor / no-email students
RootNode explicitly supports students without their own email address (younger students,
enrolled by a parent/guardian). Send `student.login_identifier` instead of (or in addition to)
`email` — any stable string you control, such as an internal account/order reference. You must
send **at least one** of `email` or `login_identifier`; sending neither is rejected.
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.
## What the student experiences after you call this
If `status` mapped to "active": the student can log in immediately (once RootNode's own
activation flow — a one-time setup link — reaches them; RootNode does not send that email/link
for you today, see "Known limitation" below) and sees the program on their dashboard right away.
If `status` mapped to "pending": the enrollment exists but the student can't start until a
RootNode instructor activates it — useful if your flow needs a manual review step before access
is granted.
**Known limitation:** RootNode does not yet send the student's activation email itself. If the
student is brand new (no prior RootNode account), your integration is responsible for letting
them know they have access and how to set up their login, until RootNode's own email delivery
exists. Ask the RootNode team for the current interim process.
## Local/staging testing
Set `INTEGRATION_SHARED_SECRET` in your test environment's config (in local dev,
`platform/.env`; example dev value used by this repo's own test suite:
`dev-integration-secret-not-for-production` — never use this value anywhere real). Then:
```bash
curl -s -X POST http://localhost:4200/api/integrations/enrollments \
-H 'Content-Type: application/json' \
-H 'X-RootNode-Integration-Key: dev-integration-secret-not-for-production' \
-d '{
"event_id": "test-order-001",
"source": "website",
"program_slug": "root-kubernetes-foundations",
"status": "paid",
"student": { "email": "[email protected]", "display_name": "Test Student" }
}'
```
All example data on this page is fake — do not reuse these values against a real deployment.
## Production secret handling
- The production `X-RootNode-Integration-Key` value must be requested from the RootNode team
and stored in your backend's secret manager/environment configuration — never in source
control, never in a browser-reachable config, never logged.
- RootNode's own logs and audit trail never record the secret's value, only that an
authenticated call happened (or a `401` if it didn't).
+8 -6
View File
@@ -7,17 +7,19 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
- `provisioner/` — Postgres-backed environment registry and lifecycle. Design:
`docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference:
`provisioner/README.md`.
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, detailed
scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
- `api/` — the lightweight Platform API: students, cohorts and cohort membership, curriculum,
detailed scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
capstone assessment engine, achievements, per-instructor accounts, persistent student/
instructor sessions, RBAC, audit logging, real student credentials + remote classrooms
(Phase 5A), and the instructor/admin control plane. No k3s, no `--privileged` — this replaced
Phase 2A's "identity" instance, which was a full Lab image booting an unused k3s cluster just
to serve these APIs. Design:
(Phase 5A), and self-paced programs/enrollment + the website integration boundary (Phase 5B),
and the instructor/admin control plane. No k3s, no `--privileged` — this replaced Phase 2A's
"identity" instance, which was a full Lab image booting an unused k3s cluster just to serve
these APIs. Design:
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split + Postgres migration),
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts, RBAC, audit),
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine),
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` (student credentials, remote classroom).
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` (student credentials, remote classroom),
`docs/PHASE5B_SELF_PACED_ENROLLMENT.md` (programs, enrollment, website integration boundary).
Operational reference: `api/README.md`.
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
routing derived from a server-controlled session — not a client-suppliable header/param — and,