feat(curriculum): Phase 6C module upgrades, security assessment bank, ROOT 11 orientation

Upgrades six PARTIAL modules to the gold-module standard (lesson/demonstration/
knowledge-check/guided-exercise/lab(s)/reflection), preserving every existing lab
activity_id and scenario_id: root-06-m03 (jobs/cronjobs/daemonsets), root-07-m04
(ingress/networkpolicy), root-07-m02 (configuration/secrets, converted in place
from a bare 5-lab bank), root-09-m02 (serviceaccounts/rbac), root-09-m03
(network/supply-chain security), root-10-m03 (troubleshooting framework).

root-05-m01 (20-task kubectl-fluency bank) gets a minimal bookending lesson +
reflection rather than a full redesign, since each task already carries its own
explanation. root-12-m01, root-capstone-m01, and root-09-m04 are promoted to
READY via documented exceptions (no new activities) where adding one would
misrepresent their intentional design.

Wires a new rootnode-security-assessment (exam, 60min, hint_policy disabled)
using the existing Phase 3B assessment engine: 3 required hands-on CORE
scenarios + 4 random-draw pools covering SecurityContext, RBAC, network
policy, and the 7 previously-unwired CKS MCQs. Wires the previously-unwired
readiness-probe-task scenario as an OPTIONAL practice lab in root-10-m01,
using the existing is_required model.

Adds root-11-m01-devops-platform-thinking — an honest, lesson-only ROOT 11
orientation module naming DevOps/platform engineering practices already
exercised in ROOT 05-10/12, explicit that GitOps/CI-CD/IaC/observability need
lab infrastructure this platform doesn't yet provide. No fake labs.

Adds infra/scripts/validate-course-content.js: cross-checks the DB against
scenario/content files for duplicate ids, orphans, invalid references, cycles,
sequence collisions, instructor-notes leakage, and (new) self-paced content
deferring explanation to a live instructor. 0 errors against the resulting
22 courses/modules, 163 activities, 98 scenario files.

No application code changed — every gap was satisfiable with existing tables
(course_modules, module_activities, assessment_definitions) and the existing
content-delivery pipeline.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-26 11:20:17 -05:00
co-authored by Claude Sonnet 5
parent 2db136875f
commit 7f6e1857c0
26 changed files with 1716 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
// Phase 6C §24 — programmatic content validation: fails loudly if the course/module/activity
// content model has any structural defect. Run against a live Postgres (same connection the
// Platform API uses) plus the repo's own scenarios/data, scenarios/rootnode-path.json, and
// content/root/ files — cross-checks the database against the filesystem rather than trusting
// either alone. Exit code 1 on any failure; safe to wire into CI later, not built as CI here
// (no CI system exists in this repo to wire into).
//
// Usage: PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
// node infra/scripts/validate-course-content.js
const fs = require('fs');
const path = require('path');
const REPO_ROOT = path.join(__dirname, '../..');
// Reuses platform/api's own `pg` dependency rather than adding a new package.json/node_modules
// just for this script — same "no new dependency where an existing one already covers it"
// convention the rest of this codebase follows.
const { Client } = require(path.join(REPO_ROOT, 'platform/api/node_modules/pg'));
const DB_URL = process.env.PLATFORM_DB_URL || 'postgres://postgres:postgres@localhost:5433/rootnode_platform';
let errors = 0;
function fail(msg) { console.error(` FAIL — ${msg}`); errors++; }
function ok(msg) { console.log(` ok — ${msg}`); }
async function main() {
const client = new Client({ connectionString: DB_URL });
await client.connect();
const scenarioIds = new Set(fs.readdirSync(path.join(REPO_ROOT, 'scenarios/data')).filter((f) => f.endsWith('.json')).map((f) => f.replace('.json', '')));
const assessmentIds = new Set((await client.query('SELECT assessment_definition_id FROM assessment_definitions')).rows.map((r) => r.assessment_definition_id));
const courses = (await client.query('SELECT * FROM courses')).rows;
const modules = (await client.query('SELECT * FROM course_modules')).rows;
const activities = (await client.query('SELECT * FROM module_activities')).rows;
const courseIds = new Set(courses.map((c) => c.course_id));
const moduleIds = new Set(modules.map((m) => m.module_id));
const activityIds = new Set(activities.map((a) => a.activity_id));
console.log(`Loaded ${courses.length} courses, ${modules.length} modules, ${activities.length} activities, ${scenarioIds.size} scenario files.\n`);
// ── No duplicate IDs (PK already enforces this at the DB level, but verify the exported
// JS-level arrays too, catching an accidental cross-table id collision the DB doesn't check) ─
for (const [label, ids] of [['course', courses.map((c) => c.course_id)], ['module', modules.map((m) => m.module_id)], ['activity', activities.map((a) => a.activity_id)]]) {
const seen = new Set(); const dupes = new Set();
for (const id of ids) { if (seen.has(id)) dupes.add(id); seen.add(id); }
if (dupes.size) fail(`duplicate ${label} id(s): ${[...dupes].join(', ')}`); else ok(`no duplicate ${label} ids`);
}
// ── No orphan modules/activities (referencing a course/module that doesn't exist) ────────
const orphanModules = modules.filter((m) => !courseIds.has(m.course_id));
if (orphanModules.length) fail(`orphan module(s) referencing a missing course: ${orphanModules.map((m) => m.module_id).join(', ')}`); else ok('no orphan modules');
const orphanActivities = activities.filter((a) => !moduleIds.has(a.module_id));
if (orphanActivities.length) fail(`orphan activity(ies) referencing a missing module: ${orphanActivities.map((a) => a.activity_id).join(', ')}`); else ok('no orphan activities');
// ── No invalid scenario/assessment references ────────────────────────────────────────────
const badScenarioRefs = activities.filter((a) => a.scenario_id && !scenarioIds.has(a.scenario_id));
if (badScenarioRefs.length) fail(`activity(ies) referencing a missing scenario file: ${badScenarioRefs.map((a) => `${a.activity_id}->${a.scenario_id}`).join(', ')}`); else ok('every lab activity scenario_id resolves to a real scenario file');
const badAssessmentRefs = activities.filter((a) => a.assessment_definition_id && !assessmentIds.has(a.assessment_definition_id));
if (badAssessmentRefs.length) fail(`activity(ies) referencing a missing assessment_definition: ${badAssessmentRefs.map((a) => `${a.activity_id}->${a.assessment_definition_id}`).join(', ')}`); else ok('every assessment/capstone activity resolves to a real assessment_definition');
// ── No impossible prerequisite references (a module/course pointing at an id that doesn't
// exist at all — distinct from Phase 6A's deliberate "PLANNED = vacuously satisfied" rule,
// this catches a genuine TYPO in a prerequisite id) ──────────────────────────────────────
for (const c of courses) {
for (const prereq of c.prerequisites || []) {
if (!courseIds.has(prereq)) fail(`course ${c.course_id} lists a prerequisite that doesn't exist: ${prereq}`);
}
}
for (const m of modules) {
for (const prereq of m.prerequisites || []) {
if (!moduleIds.has(prereq)) fail(`module ${m.module_id} lists a prerequisite that doesn't exist: ${prereq}`);
}
}
ok('every course/module prerequisite id resolves to a real course/module (or was already reported above)');
// ── No course-tree cycles (a module/course that is its own transitive prerequisite) ──────
function hasCycle(items, idKey) {
const byId = new Map(items.map((i) => [i[idKey], i]));
const state = new Map(); // id -> 'visiting' | 'done'
let cyclic = null;
function visit(id, chain) {
if (cyclic) return;
const item = byId.get(id);
if (!item) return;
if (state.get(id) === 'visiting') { cyclic = [...chain, id]; return; }
if (state.get(id) === 'done') return;
state.set(id, 'visiting');
for (const prereq of item.prerequisites || []) visit(prereq, [...chain, id]);
state.set(id, 'done');
}
for (const item of items) visit(item[idKey], []);
return cyclic;
}
const courseCycle = hasCycle(courses, 'course_id');
if (courseCycle) fail(`course prerequisite cycle: ${courseCycle.join(' -> ')}`); else ok('no course prerequisite cycles');
const moduleCycle = hasCycle(modules, 'module_id');
if (moduleCycle) fail(`module prerequisite cycle: ${moduleCycle.join(' -> ')}`); else ok('no module prerequisite cycles');
// ── No sequence collisions within one module/course (two siblings sharing a sequence
// number is a real authoring bug — found and fixed once already in Phase 6B, see
// 023_phase6b_curriculum.sql's ROOT 07 m02/m04 comment) ──────────────────────────────────
function checkSequenceCollisions(items, groupKey, label) {
const groups = new Map();
for (const item of items) {
const key = item[groupKey];
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(item.sequence);
}
let collided = 0;
for (const [key, seqs] of groups) {
const dupes = seqs.filter((s, i) => seqs.indexOf(s) !== i);
if (dupes.length) { fail(`${label} sequence collision in ${key}: sequence ${[...new Set(dupes)].join(',')} used more than once`); collided++; }
}
if (!collided) ok(`no ${label} sequence collisions`);
}
checkSequenceCollisions(modules, 'course_id', 'module');
checkSequenceCollisions(activities, 'module_id', 'activity');
// ── Instructor notes never leak to students (content.js's own splitContent, exercised
// directly here rather than trusted). content.js resolves content_path against
// CONTENT_ROOT_DIR if set, defaulting to one level up from its own src/ (correct only INSIDE
// the Docker image, where the Dockerfile COPYs content/ next to src/) — running it directly
// on the host here needs this override so it resolves against the real repo root instead.
process.env.CONTENT_ROOT_DIR = REPO_ROOT;
const { loadContent } = require(path.join(REPO_ROOT, 'platform/api/src/content.js'));
let leakChecked = 0, leaked = 0;
for (const a of activities) {
if (!a.content_path) continue;
const c = loadContent(a.content_path);
leakChecked++;
if (c && c.shared && c.shared.includes('## Instructor Notes')) { fail(`${a.activity_id}: 'shared' (student-visible) content still contains the Instructor Notes heading — split failed`); leaked++; }
}
if (!leaked) ok(`instructor notes correctly excluded from student-visible content in all ${leakChecked} activities with a content_path`);
// ── No content_path referencing a missing file ───────────────────────────────────────────
let missingContent = 0;
for (const a of activities) {
if (!a.content_path) continue;
if (!fs.existsSync(path.join(REPO_ROOT, a.content_path))) { fail(`${a.activity_id}: content_path does not exist on disk: ${a.content_path}`); missingContent++; }
}
if (!missingContent) ok('every content_path resolves to a real file on disk');
// ── A 'lab' activity must have a scenario_id; an 'assessment'/'capstone' must have an
// assessment_definition_id (the DB CHECK constraints already enforce this — verified here
// too so this script fails loudly even against a hand-edited row that somehow bypassed it) ─
const labsMissingScenario = activities.filter((a) => a.activity_type === 'lab' && !a.scenario_id);
if (labsMissingScenario.length) fail(`lab activity(ies) with no scenario_id: ${labsMissingScenario.map((a) => a.activity_id).join(', ')}`); else ok('every lab activity has a scenario_id');
const assessmentsMissingDef = activities.filter((a) => ['assessment', 'capstone'].includes(a.activity_type) && !a.assessment_definition_id);
if (assessmentsMissingDef.length) fail(`assessment/capstone activity(ies) with no assessment_definition_id: ${assessmentsMissingDef.map((a) => a.activity_id).join(', ')}`); else ok('every assessment/capstone activity has an assessment_definition_id');
// ── Section 16/17 — a module marked READY (self-paced-sufficient) must not defer explanation
// to a live instructor in its student-visible content. Scans the actual 'shared' half of
// content.js's real split (never the instructor-notes half, which is explicitly ALLOWED to
// say "explain live"), and only against READY modules — instructor-led-only PARTIAL modules
// (see docs/ROOT_PILOT_CURRICULUM.md) are not held to the self-paced bar. ───────────────────
const readyModuleIds = new Set(modules.filter((m) => m.status === 'ready').map((m) => m.module_id));
const deferralPhrases = [/your instructor will explain/i, /ask your instructor/i, /your instructor will cover/i];
let deferralsFound = 0;
for (const a of activities) {
if (!a.content_path || !readyModuleIds.has(a.module_id)) continue;
const c = loadContent(a.content_path);
if (!c || !c.shared) continue;
if (deferralPhrases.some((re) => re.test(c.shared))) { fail(`${a.activity_id}: self-paced-marked module's student-visible content defers to a live instructor`); deferralsFound++; }
}
if (!deferralsFound) ok('no READY module\'s student-visible content defers explanation to a live instructor');
// ── Duplicate scenario mapping — flagged, not failed, since Phase 6A/6B both document
// DELIBERATE reuse (rootnode-probe-port-mismatch in both the gold module and Real Ops;
// pod-security-context/rbac-clusterrole as documented independent-practice duplicates). This
// prints the list for a human to confirm against docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md's
// 'duplicate concepts identified' section rather than failing the build. ───────────────────
const scenarioUsage = new Map();
for (const a of activities) {
if (!a.scenario_id) continue;
if (!scenarioUsage.has(a.scenario_id)) scenarioUsage.set(a.scenario_id, []);
scenarioUsage.get(a.scenario_id).push(a.activity_id);
}
const reused = [...scenarioUsage.entries()].filter(([, ids]) => ids.length > 1);
console.log(` info — ${reused.length} scenario(s) intentionally reused across multiple activities (see docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md):`);
for (const [sid, ids] of reused) console.log(` ${sid}: ${ids.join(', ')}`);
await client.end();
console.log(`\n${errors === 0 ? 'PASS' : 'FAIL'} — ${errors} error(s)`);
process.exit(errors ? 1 : 0);
}
main().catch((e) => { console.error('FATAL', e); process.exit(1); });