-- Assessment engine: definitions (exam/capstone templates), cohort assignment, live sessions, -- and the per-session scenario draw. exam_sessions (migration 006) is left untouched — it was -- already scoped in Phase 2B as historical-import-only from the old Lab-local SQLite exam -- bookkeeping, never the live mechanism. These tables ARE the live mechanism; duplicating -- exam_sessions' purpose would mean building the real thing twice, so this doesn't reuse it. -- -- Definitions are lightly versioned (a plain incrementing integer, not full history) so a -- later edit to pool_spec/duration/hint_policy never changes what an already-started session is -- graded against — every session snapshots the fields that matter at draw time. CREATE TABLE IF NOT EXISTS assessment_definitions ( assessment_definition_id TEXT PRIMARY KEY, -- slug, e.g. 'rootnode-capstone' type TEXT NOT NULL CHECK (type IN ('exam', 'capstone')), title TEXT NOT NULL, description TEXT, duration_minutes INTEGER NOT NULL, hint_policy TEXT NOT NULL DEFAULT 'disabled' CHECK (hint_policy IN ('free', 'disabled', 'flagged')), pool_spec JSONB NOT NULL, -- {"required": [scenario_id,...], "random": [{"count":n,"from":[scenario_id,...]},...]} completion_rule JSONB NOT NULL DEFAULT '{"type":"all_scenarios_passed"}', unlock_rule JSONB, -- capstone only: {"type":"stages_complete","stage_ids":[...]} version INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), created_by UUID REFERENCES instructors(instructor_id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS assessment_cohort_assignments ( assessment_definition_id TEXT NOT NULL REFERENCES assessment_definitions(assessment_definition_id), cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id), assigned_by UUID REFERENCES instructors(instructor_id), assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (assessment_definition_id, cohort_id) ); CREATE TABLE IF NOT EXISTS assessment_sessions ( assessment_session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), assessment_definition_id TEXT NOT NULL REFERENCES assessment_definitions(assessment_definition_id), definition_version INTEGER NOT NULL, -- snapshot: which version of the definition was drawn against student_id TEXT NOT NULL REFERENCES students(student_id), cohort_id TEXT, type TEXT NOT NULL, -- snapshot title TEXT NOT NULL, -- snapshot duration_minutes INTEGER NOT NULL, -- snapshot hint_policy TEXT NOT NULL, -- snapshot status TEXT NOT NULL DEFAULT 'not_started' CHECK (status IN ('not_started', 'in_progress', 'completed', 'expired', 'cancelled')), started_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, -- server-computed = started_at + duration_minutes; authoritative, never client-set submitted_at TIMESTAMPTZ, random_seed TEXT, -- recorded for audit/reproducibility, not exposed to the student before the draw result JSONB, -- {"scenarios_total":n,"scenarios_passed":n,"passed":bool} created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS assessment_session_scenarios ( id BIGSERIAL PRIMARY KEY, assessment_session_id UUID NOT NULL REFERENCES assessment_sessions(assessment_session_id), scenario_id TEXT NOT NULL, position INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'failed')), attempt_id BIGINT REFERENCES scenario_attempts(attempt_id), UNIQUE (assessment_session_id, scenario_id) ); DO $$ BEGIN ALTER TABLE scenario_attempts ADD CONSTRAINT scenario_attempts_assessment_session_fk FOREIGN KEY (assessment_session_id) REFERENCES assessment_sessions(assessment_session_id); EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE INDEX IF NOT EXISTS idx_assessment_sessions_student ON assessment_sessions(student_id, status); CREATE INDEX IF NOT EXISTS idx_assessment_sessions_definition ON assessment_sessions(assessment_definition_id); CREATE INDEX IF NOT EXISTS idx_assessment_session_scenarios_session ON assessment_session_scenarios(assessment_session_id); -- Seed defaults — a working RootNode Capstone and one example exam ship out of the box, per the -- Phase 3B spec ("ship with a sensible RootNode default"). Scenario ids are a snapshot of -- scenarios/rootnode-path.json's stage lists as of this migration; edit the definition via the -- admin API if curriculum content changes later, same as any other admin-owned config. INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule) VALUES ( 'rootnode-capstone', 'capstone', 'RootNode Capstone', 'Combines skills from every curriculum stage into one operational assessment, weighted toward RootNode Real Ops incident scenarios. Evaluates operational ability, not memorized kubectl syntax.', 90, 'flagged', '{"required": ["rootnode-runaway-monitor","broken-deployment","rootnode-probe-port-mismatch","rootnode-enforce-limits"], "random": [{"count":1,"from":["deploy-nginx","scale-deployment","rolling-update-task","liveness-probe-task","edit-deployment-basics"]},{"count":1,"from":["resource-limits-task","env-vars-configmap","secrets-basics"]},{"count":1,"from":["extract-logs-basics","kubectl-cp-basics","exec-command-basics","node-cordon-basics"]}]}', '{"type":"all_scenarios_passed"}', '{"type":"stages_complete","stage_ids":["foundation","workloads","networking","configuration","troubleshooting","real-ops"]}' ) ON CONFLICT (assessment_definition_id) DO NOTHING; INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule) VALUES ( 'rootnode-quick-assessment', 'exam', 'RootNode Kubernetes Assessment', 'A timed, randomized skills check drawn from across the curriculum. Example exam definition — instructors can create their own from the admin portal.', 60, 'disabled', '{"required": [], "random": [{"count":2,"from":["deploy-nginx","scale-deployment","rolling-update-task","liveness-probe-task","daemonset-mcq"]},{"count":1,"from":["services-mcq","expose-service-basics"]},{"count":1,"from":["extract-logs-basics","kubectl-cp-basics","exec-command-basics","node-cordon-basics"]},{"count":1,"from":["rootnode-runaway-monitor","broken-deployment","rootnode-probe-port-mismatch","rootnode-enforce-limits"]}]}', '{"type":"all_scenarios_passed"}' ) ON CONFLICT (assessment_definition_id) DO NOTHING;