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.
23 KiB
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_secondscomputed) 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:
{ "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:
{ "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": 1integer inrootnode-path.jsonitself, recorded onstage_completed/curriculum_completedachievements as a snapshot. - Assessment definitions: a plain incrementing
versioninteger, 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_versionsnapshots 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_atfixed at draw time, never accepts a client value, checked lazily on every touch. - Scenario draw is server-stored —
assessment_session_scenariosis 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.studentIdserver-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
/startand/hintrelays. - 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
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_progressforever; there's no reaper tying Lab destruction back to attempt state. A documented, deliberate scope line (would need wiring to Provisionerenvironment_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.