Files
rootnode-academy/docs/PHASE3B_LEARNING_AND_ASSESSMENT.md
ced4568 45a147a61f docs(phase3b): document the learning and assessment engine
New docs/PHASE3B_LEARNING_AND_ASSESSMENT.md covers the attempt/learning-record model,
progressive hints (including the Gateway response-rewrite mechanism), curriculum completion
rules, the assessment/exam engine and its timer/draw integrity guarantees, the RootNode
Capstone design, achievement records, the deliberately minimal versioning model, instructor
analytics, the Phase 3B RBAC matrix, security/integrity assumptions, dev/test commands, known
limitations, and the Phase 4 boundary.

Updates ENGINEERING_SOP.md (roadmap items 2/7/8 now done, new section 12, corrects a stale
capstone-placeholder description in section 4), LAB_ARCHITECTURE.md's status line,
README.md (corrects the 'Lab image unmodified' claim now that its frontend bundle carries
Phase 3B UX, plus new platform properties), platform/README.md, and platform/api/README.md.
2026-08-18 14:36:13 -05:00

371 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 3B — Learning & Assessment Engine
Turns the platform infrastructure built in Phases 1–3A (identity, sessions, RBAC, disposable
Labs, Gateway routing, admin portal) into a real teaching and assessment system: detailed
attempt telemetry, progressive hints, rule-driven curriculum progression, a proper exam engine,
a RootNode Capstone, and persistent achievement records. Builds on
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` — read that first if you haven't.
## 1. What changed, in one paragraph
Phase 2B's `scenario_attempts` table was one row per validation click, written atomically at
completion, with `hint_count` deliberately left unmeasured. Phase 3B evolves it into a real
attempt lifecycle — one row per continuous working session on a scenario, with live counters
(validation attempts/passes/fails, hint reveals, duration) updated in place as the student
works. On top of that: a rule-driven curriculum-progression engine that actually locks/unlocks
stages instead of a permanent capstone placeholder; a progressive hint system that's genuinely
server-enforced (not just collapsed-by-default in the UI); a full exam/capstone assessment
engine with server-authoritative timers and random scenario draws; and persistent achievement
records for stage/curriculum/assessment/capstone completion.
## 2. Learning record model
`scenario_attempts` (migration `012`) gained: `cohort_id`, `curriculum_stage` (both snapshotted
at attempt-start, so a later cohort/curriculum change never rewrites history), `status`
(`in_progress`/`completed`/`abandoned`), `validation_attempt_count`, `validation_pass_count`,
`validation_fail_count`, `highest_hint_level`, `duration_seconds`, `assessment_session_id`.
`result` (Phase 2B) is now nullable — meaningful only once `status = 'completed'`.
`hint_count` (Phase 2B, always `NULL`) finally gets a `DEFAULT 0` and real increments.
This is an **evolution, not a new table** — Phase 2B's historical rows are backfilled (one
validation performed, matching their original one-row-per-click semantics), not dropped or
reshaped. `platform/api/src/attempts.js` owns the lifecycle:
- `getOrCreateOpenAttempt` — finds the open (`in_progress`) attempt for
`(student, scenario, environment)`, or starts one. Environment-scoped so a destroyed/recreated
Lab never silently resumes an attempt tied to compute that no longer exists.
- `recordValidation` — increments the attempt/pass/fail counters; closes the attempt
(`status='completed'`, `duration_seconds` computed) only on a pass.
- `recordHintReveal` — sequential only: level *N* can't be revealed before *N-1* has been.
Returns a 409 once every hint is exhausted.
No telemetry is invented that the Lab doesn't actually emit — `duration_seconds` is measured
server-side from `started_at` to completion (not client-reported), and hint reveals only exist
because Phase 3B builds a real reveal endpoint (§3), not because the old static hint array was
reinterpreted as "measured."
## 3. Validation telemetry & progressive hints
`POST /api/scenarios/:id/start` — fire-and-forget from the Lab UI when a scenario becomes
active (both task and MCQ types); creates/reopens the attempt with a real `started_at`.
`POST /api/progress/record` (Gateway-relayed from the Lab's existing validate/answer calls,
unchanged mechanism from Phase 2A/2B) now routes through `attempts.recordValidation` instead of
a flat insert, and — on a pass — checks curriculum/capstone/assessment consequences (§4, §6).
`POST /api/scenarios/:id/hint` reveals hints **one at a time, server-enforced**. This required a
real architectural change: Phase 2B's Lab backend (unmodified, per the "Lab image stays
unmodified" convention through Phase 3A) returns the scenario's full `hints` array in
`GET /api/scenarios/:id` — always did, since the Lab has no student/attempt concept at all. A
client could already see hint 3 in the raw response even with the UI collapsed. Making the gate
real meant the Gateway now **strips `hints` from that response** (replacing it with a bare
`hint_count`) for exactly the multi-student, Platform-mediated deployment
(`platform/gateway/src/server.js`, `GET /lab/:token/api/scenarios/:scenarioId`) — the Lab
image's own bytes are still untouched; this is a response rewrite at the boundary, the same
pattern already used for the `hints`-stripping shape as everything else the Gateway mediates.
Standalone/single-container dev mode never goes through the Gateway, so it keeps the original
fully-preloaded array unchanged — the frontend detects which mode it's in
(`scenario.hints === undefined && typeof scenario.hint_count === 'number'`) and switches to
`ProgressiveHints` (`frontend/src/components/ProgressiveHints.jsx`) only in the gated case.
Instructor-visible attempt summary matches the shape in the Phase 3B spec directly:
```
Student: Alice Scenario: Probe Port Mismatch Attempts: 2
Validation: Failed 2, Passed 1 Hints used: 1/3 Time: 14m 22s Final: Completed
```
— all straight off `scenario_attempts` columns, no derived/estimated numbers, and explicitly
**not** turned into a score. See §12 for where instructors see this.
## 4. Curriculum completion rules
`scenarios/rootnode-path.json` gained a `version` field and, per stage, a `completion_rule`:
```json
{ "type": "all_required" } // every scenario_id in the stage must be completed (default)
{ "type": "threshold", "percent": 80 } // at least N% completed
{ "type": "prerequisites", "scenario_ids": [...] } // only these specific ids must be completed
```
The capstone stage is no longer a hardcoded `"locked": true` placeholder — it carries
`"type": "capstone"`, an `"unlock_rule"` (`{"type":"stages_complete","stage_ids":[...]}`), and a
`"capstone_assessment_id"` pointing at the assessment definition that drives it (§6/§7).
`platform/api/src/curriculum-rules.js`'s `computeCurriculumState(path, progress, capstoneStatus)`
is the rule engine — pure, synchronous, no DB access — answering: per-stage locked/complete/
percent/next-scenario, the student's current stage, overall percent complete, and the capstone's
unlock status (server route supplies `capstoneStatus` after checking for a
`capstone_completed` achievement, since that's Postgres state this module doesn't touch).
Exposed at `GET /api/curriculum/state` (new; `GET /api/curriculum/path` from Phase 2A/2B is
unchanged and still used by the pre-existing dashboard summary).
Stage/curriculum completion also triggers achievement recording (§8) automatically, from inside
`POST /api/progress/record` — no separate "check for completion" step the student or frontend
has to remember to call.
## 5 & 6. Assessment model and exam engine
`PRACTICE` is just... practice: the existing curriculum flow, hints freely available (subject to
the scenario actually having any), unrestricted retries, no timer, no session concept.
`ASSESSMENT` (exam or capstone — same underlying engine, §7 covers what makes a capstone
different) is a **definition** (the reusable template: pool, duration, hint policy, completion
rule) producing **sessions** (one per student attempt, immutable snapshot of what was drawn and
under what rules). New tables (migration `013`):
```
assessment_definitions slug PK, type ('exam'|'capstone'), title, duration_minutes,
hint_policy ('free'|'disabled'|'flagged'), pool_spec JSONB,
completion_rule JSONB, unlock_rule JSONB (capstone only),
version INTEGER, status ('active'|'inactive')
assessment_cohort_assignments which cohorts a definition is available to
assessment_sessions one per student attempt — snapshots type/title/duration_minutes/
hint_policy from the definition at draw time, plus
definition_version, started_at, server-computed expires_at,
submitted_at, random_seed, result JSONB
assessment_session_scenarios the drawn set, ordered, with per-scenario status + a real FK
back to the scenario_attempts row once worked
```
`exam_sessions` (Phase 1/2B, historical-import-only from the old Lab-local SQLite exam
bookkeeping) is **left untouched** — it was already scoped as dead historical data, never the
live mechanism, so there was nothing to migrate into the tables above without inventing data
that was never real. This is a deliberate non-duplication: the assessment tables above *are*
the real thing Phase 2B's note said didn't exist yet.
`pool_spec` shape:
```json
{ "required": ["scenario-id", ...], "random": [{ "count": 1, "from": ["a", "b", "c"] }, ...] }
```
`platform/api/src/assessments.js`'s `drawScenarios` includes every `required` id, plus `count`
random picks from each `random` group, shuffled together into presentation order. The draw uses
a tiny deterministic PRNG (mulberry32, seeded from `crypto.randomBytes` — no new dependency)
purely so the seed can be recorded for audit; a student never sees the seed or the undrawn pool
before starting.
**One assessment at a time per student** — `startSession` refuses if the student already has a
`not_started`/`in_progress` session for *any* definition. This keeps "which scenario is this
validate call happening inside" unambiguous for hint-policy enforcement (§19) without needing
the client to declare a mode.
**Timer**: `expires_at` is computed once, server-side, at `started_at + duration_minutes`, and
never recomputed from anything the client sends. Expiry is checked **lazily** — whenever a
session is read or written, if `now() > expires_at` and it's still `in_progress`, it flips to
`expired` right then. No background cron (explicitly out of scope this phase — see the
non-goals list). Verified live: a 1-minute test definition correctly transitioned to `expired`
on the first request after the minute passed, and unblocked starting a new assessment.
## 7. RootNode Capstone
A capstone is an `assessment_definitions` row with `type='capstone'` — same engine as an exam,
different framing and unlock gate. The shipped default (seeded in migration `013`, idempotent
`INSERT ... ON CONFLICT DO NOTHING`) is `rootnode-capstone`:
- **Required** (always drawn): all four RootNode Real Ops scenarios
(`rootnode-runaway-monitor`, `broken-deployment`, `rootnode-probe-port-mismatch`,
`rootnode-enforce-limits`) — "feature prominently," per the spec, satisfied by making them
non-optional rather than just likely.
- **Random**: one Workloads task, one Configuration task, one Troubleshooting task — task-type
scenarios preferred over MCQ, since the point is operational ability, not recall.
- 90 minutes, `hint_policy: 'flagged'` (available, but every use is recorded — appropriate for
a capstone that should mostly reflect independent work without being punitive about needing a
nudge).
- `unlock_rule`: every non-capstone curriculum stage complete (Foundation through Real Ops).
`GET /api/capstone` is a student-friendly view combining `computeCurriculumState`'s capstone
block with the definition and the student's own session history —
`locked` → `unlocked` → `completed`, with a prerequisite checklist while locked. Verified live,
end to end: locked with all six blocking stage ids, unlocked to zero blocking ids the moment the
last stage completed, a real draw (7 scenarios, all four Real Ops present), completion of all
seven finalizing the session and recording a `capstone_completed` achievement.
## 8. Achievement / completion records
`achievements` (migration `014`): `student_id`, `achievement_type`
(`stage_completed`/`curriculum_completed`/`assessment_completed`/`capstone_completed`),
`curriculum_path_id`/`curriculum_version` (for stage/curriculum types),
`assessment_session_id` (for assessment/capstone types), `achieved_at`, `metadata`.
`recordAchievement` is idempotent via a `WHERE NOT EXISTS` guard (not a unique-index/
`ON CONFLICT` dance — simple and correct at this write volume) — calling it again for something
already recorded is a safe no-op. Triggered automatically: `checkStageAndCurriculumAchievements`
runs inside `POST /api/progress/record` after every pass; `recordAssessmentAchievement` runs
after `assessments.recordScenarioOutcome`/`submitSession` finalizes a session, and only on an
actual pass (defensive — today's only `completion_rule` is `all_scenarios_passed`, which already
guarantees a `completed` session passed, but a future threshold-based rule shouldn't silently
grant achievements for a partial result).
**Not a certificate.** No public/shareable artifact is generated — this is explicitly a Phase
4/5 concern (see the non-goals list); a certificate can be rendered from these records later
without needing new data.
## 9. Assessment/exam Lab behavior
An assessment doesn't provision a separate Lab — the student works through their drawn
scenarios inside the **same** disposable environment their curriculum work already uses. This
is what makes "Lab reset doesn't erase assessment state" nearly free: assessment state
(`assessment_sessions`, `assessment_session_scenarios`, `scenario_attempts`) lives entirely in
Platform Postgres; the Lab environment is purely compute. Resetting/recreating it changes the
container, never the rows. Verified live: reset an environment mid-assessment,
`expires_at`/`status` on the session were unchanged afterward.
**Assessment-mode detection is entirely server-derived** — `findActiveAssessmentContextForScenario`
looks up whether the scenario a `validate`/`hint` call is happening against is currently in the
student's own active session's drawn set. Nothing the client sends declares "I'm in assessment
mode"; the server's own `assessment_session_scenarios` table is the only source of truth (see
§19).
If Lab infrastructure fails mid-assessment, the assessment session itself is unaffected — an
instructor can reset (or, if truly needed, destroy/reprovision) the student's Lab via the
existing admin environment controls (Phase 2B) without touching assessment state at all; there
is no code path that resets an assessment as a side effect of a Lab operation.
## 10 & 11. Student UI and Lab UX
`frontend/src/platform/StudentDashboard.jsx` is now tabbed: **My Learning** (the original
Phase 2A/2B "Up Next" + Launch/Reset Lab flow, extracted unchanged into `MyLearningTab.jsx` and
extended with an overall percent-complete bar from the new rule-driven curriculum state),
**Practice** (scenario/stage status + recent attempt history), **Assessments** (available/
active-with-live-countdown/history), **Capstone** (locked checklist → unlocked → start/resume →
completed, with attempt history), **Achievements** (a flat, dated list — not a leaderboard).
Inside the Lab UI itself (`frontend/src/components/ScenarioPanel.jsx`, `App.jsx`):
`ProgressiveHints` replaces the static hint array render when the Gateway has stripped hints
(§3); an `AssessmentBanner` (new, top-level, polls `/api/assessments/active`) shows a live
countdown and per-scenario completion count whenever an assessment is running — distinct from,
and rendered alongside, the pre-existing Lab-local exam-mode UI (`ExamTimer`/`ExamSession` etc.,
upstream KubeKosh feature, untouched). Both render nothing in standalone/single-container dev
mode (no Platform API to poll there), so neither regresses that deployment path.
## 12. Instructor learning analytics
`/admin` → **Learning**: cohort summary (student count, 7-day active-learner count, average
completion, per-stage completion counts), a **Needs Attention** list, and a per-student
drill-down (curriculum state, full attempt history with validation/hint counts, assessment
history). `/admin` → **Assessments**: create/edit definitions (admin/owner), assign to a cohort,
view/cancel sessions — visible to every instructor role, cohort-scoped server-side for the plain
`instructor` role the same way Phase 3A's other cohort-scoped routes are.
`GET /api/admin/scenario-analytics` (optional `?cohort_id=`): completions, distinct students,
average validation attempts, total failures, total hints used, average completion time — one
`GROUP BY scenario_id` query over `scenario_attempts`, nothing pre-aggregated or cached.
## 13. "Needs Attention"
`GET /api/admin/cohorts/:id/needs-attention` computes four signals per student, on read (no
stored table, no background job): **Repeated Attempts** (an open attempt with ≥3 failed
validations), **May Need Help** (an open attempt where a hint has been used but the scenario
isn't done), **Needs Attention — inactive** (no activity in 7 days), and **Needs Attention —
incomplete assessment** (their most recent assessment session expired without completing). All
four thresholds are named constants in `admin-learning-routes.js`
(`NEEDS_ATTENTION_FAIL_THRESHOLD`, `NEEDS_ATTENTION_STALE_DAYS`), not hardcoded inline. Wording
is deliberately instructional (`Needs Attention`, `May Need Help`, `Repeated Attempts`) — an
explicit test asserts none of the returned labels match punitive language.
## 14 & 15. Assessment/capstone management (admin)
A structured form (`frontend/src/admin/AssessmentsView.jsx`) — id, title, type, duration, hint
policy, and a raw JSON `pool_spec` textarea. Not a drag-and-drop authoring tool, per the explicit
scope line; the JSON textarea is honest about what's actually being edited rather than hiding it
behind partial UI that can't express every pool shape. Editing a definition's
duration/hint_policy/pool_spec/completion_rule bumps its `version` automatically
(`admin-learning-routes.js` diffs the incoming fields against the current row) — cosmetic edits
(title, description) don't. Create/edit is admin/owner only server-side; assign-to-cohort and
session view/cancel are available to a scoped `instructor` too, matching the RBAC matrix (§18).
## 16. Versioning
Deliberately minimal, per the explicit "don't overbuild full CMS versioning" instruction:
- **Curriculum path**: a plain `"version": 1` integer in `rootnode-path.json` itself, recorded
on `stage_completed`/`curriculum_completed` achievements as a snapshot.
- **Assessment definitions**: a plain incrementing `version` integer, bumped only on a semantic
change (duration/hint_policy/pool_spec/completion_rule — anything that would change what a
session is graded against). `assessment_sessions.definition_version` snapshots which version
was live at draw time, alongside a full redundant snapshot of the fields themselves
(title/duration/hint_policy) — so a later definition edit, or even deletion, can never change
what an already-started session is measured against.
No full version-history table exists for either — if that's ever needed, `version` is already a
column to build on, not a retrofit.
## 17. RBAC matrix (Phase 3B additions)
| Action | owner | admin | instructor | student |
|---|---|---|---|---|
| Create/edit assessment definitions | ✅ | ✅ | ❌ | ❌ |
| Assign an assessment to a cohort | ✅ | ✅ | ✅ (own cohorts) | ❌ |
| View/cancel assessment sessions | ✅ (any) | ✅ (any) | ✅ (own cohorts) | ❌ |
| View cohort learning summary / needs-attention / scenario analytics | ✅ (global) | ✅ (global) | ✅ (own cohorts) | ❌ |
| View a student's learning detail | ✅ | ✅ | ✅ (if enrolled in an owned cohort) | own only |
| Start/work an assessment, view own attempts/hints/achievements | ❌ | ❌ | ❌ | ✅ (own only) |
Enforced entirely server-side, reusing Phase 3A's `rbac.js` (`requireAdminOrOwner`,
`requireAnyInstructor`, `requireCohortAccess`, `requireStudentCohortAccess`) — no new
authorization primitives were needed, only new routes wired through the existing ones.
## 18. Security / integrity
- **Timer is server-authoritative** (§6) — `expires_at` fixed at draw time, never accepts a
client value, checked lazily on every touch.
- **Scenario draw is server-stored** — `assessment_session_scenarios` is the only source of
truth for what a student is supposed to be working on; nothing the client sends can add,
remove, or reorder it.
- **A student cannot submit/read another student's session** — every assessment route filters
by `student_id = req.studentId` server-side; a foreign session id returns 404, not 403 (same
"don't confirm existence" posture as Phase 3A's RBAC denials) — verified by an explicit test.
- **Gateway ownership** (Phase 2A/2B/3A) is unchanged and still enforced on every proxied Lab
request, HTTP and WebSocket, including the new `/start` and `/hint` relays.
- **Lab recreation never resets attempt/exam state** (§9) — verified live.
- **Hidden solution/pool data**: undrawn pool alternatives are never returned to the student
(only the actual draw); hints beyond the currently-unlocked level are never sent (§3, the
actual mechanism, not just a UI convention).
- **No invasive anti-cheat** — no browser surveillance, no lockdown mode. Explicitly out of
scope, per the non-goals list.
## 19. Dev/test commands
```bash
cd platform && docker compose -f docker-compose.dev.yml up -d
(cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "Your Name" "a-real-password-12-chars-plus")
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase2a.js
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase2b.js
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase3a.js
OWNER_EMAIL=[email protected] OWNER_PASSWORD=... node test/e2e-phase3b.js
```
`e2e-phase3b.js` additionally shells out to `docker compose restart api` for its restart-
persistence case (set `SKIP_RESTART_TEST=1` to skip that one case if running somewhere that
isn't safe to restart).
## 20. Known limitations
- **No automatic "abandoned" detection** — an attempt a student never returns to stays
`in_progress` forever; there's no reaper tying Lab destruction back to attempt state. A
documented, deliberate scope line (would need wiring to Provisioner `environment_events`,
more machinery than the instructional-visibility goal needs).
- **`cohort_instructors.role_in_cohort`** (Phase 3A) still isn't consulted for finer-grained
assessment permissions — any cohort assignment grants the full scoped-instructor action set.
- **Needs-attention thresholds are fixed constants**, not per-cohort configurable yet.
- **No pagination** on the audit log (Phase 3A limitation, unchanged) or on scenario analytics.
- **Assessment pool_spec authoring is raw JSON** in the admin UI — correct and complete, but not
a guided form; a validation-assist UI is a natural but unbuilt next step.
- **One assessment at a time per student** — intentional (§6), but means a student can't have a
capstone and an exam "in flight" simultaneously even if that were ever desired.
## 21. Phase 4 boundary
Everything in this document runs on a single dev machine against
`platform/docker-compose.dev.yml`. **Not built, and explicitly out of scope this phase:**
production Hetzner/Proxmox deployment, production OIDC provider integration, a parent portal,
payments, public signup, automated email, equipment inventory, a multi-host scheduler,
Grafana/Prometheus production dashboards, public certificates, a marketplace/self-paced commerce
layer, the six held KubeKosh scenarios, invasive proctoring, and AI-driven grading. All of that
stays exactly where the Phase 3A doc's non-goals list and this document's §21 leave it — Phase 4
territory.