Files
rootnode-academy/docs/COURSE_CONTENT_MODEL.md
T
ced4568andClaude Sonnet 5 fe702754cd feat(content): add course/module/activity content model
New tables (courses, course_modules, module_activities,
activity_completions) layered over the existing scenario/assessment/
achievement systems — a lab activity references an existing scenario_id,
an assessment/capstone activity references an existing
assessment_definition_id, neither duplicated. Long-form lesson prose
lives in git-tracked Markdown (content/root/), split into shared vs.
instructor-only sections by a '## Instructor Notes' heading convention
(content.js) rather than a second file or a CMS.

course-progress.js derives completion from EXISTING sources only:
auto_scenario reads the progress table, auto_assessment reads
achievements by assessment_definition_id, manual_marker is the one
genuinely new piece of state (for lesson-only activities with nothing
else to derive completion from). Prerequisite locking treats a
zero-content (PLANNED) prerequisite as vacuously satisfied, so an
honestly-unbuilt course never permanently locks everything after it.

Found by testing: computing a level's prerequisite lock and stripping
its working totals in the same map iteration made the result depend on
iteration order. Fixed with two explicit passes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-25 23:29:13 -05:00

7.1 KiB

Course Content Model

Phase 6A §3 — how RootNode represents actual teaching content (lessons, demonstrations, knowledge checks, guided exercises, reflections) around the existing scenario/assessment systems, without duplicating either. See docs/ROOT_PROGRAM_ARCHITECTURE.md for what this model is populated with, and docs/COURSE_AUTHORING_STANDARD.md for how to author new content against it.

1. Shape

program (existing, 019_programs.sql — unchanged)
  → course            (new: courses)
      → module         (new: course_modules)
          → activity    (new: module_activities)

platform/api/migrations/022_course_content.sql is the schema. Every table follows the same conventions already established by programs/assessment_definitions: a TEXT slug primary key, a plain incrementing version integer bumped by hand on a semantic edit, status/visibility CHECK constraints, created_at/updated_at.

2. Activity types and what they reference

Type References Completion behavior
lesson, demonstration, knowledge_check, guided_exercise, reflection a Markdown file (content_path) manual_marker
lab an EXISTING scenario_id (scenarios/data/*.json) auto_scenario
assessment, capstone an EXISTING assessment_definition_id (assessment_definitions) auto_assessment

A lab activity never duplicates scenario content — it stores a bare scenario_id string, the same id the existing curriculum/attempt/progress system already uses. An assessment/capstone activity never duplicates an assessment definition — it stores the existing assessment_definition_id and a foreign key enforces it actually exists. Neither activity type is prevented from also having its own content_path (e.g. the gold module's lab activity has a short Markdown intro at 05-lab-intro.md) — that's framing prose around the reference, not a copy of the referenced thing.

3. Where the prose lives

Long-form lesson content is git-tracked Markdown under content/root/<course>/<module>/, not Postgres. Reasoning, weighed explicitly per the authorization's instruction:

  • Reviewable like code. A lesson edit is a normal diff/PR, not an opaque row update.
  • No CMS needed. The authorization explicitly ruled one out ("Do not build a complex CMS"). A Postgres TEXT column holding Markdown would technically work but loses git history/diffing for the one thing (prose) that benefits from it most.
  • Structural metadata still belongs in Postgres (ordering, type, references, status, objectives, prerequisites) because it's queried, joined, and mutated (locking/completion) — exactly the kind of relational state Postgres is for, and exactly what the authorization asked for ("Postgres preferred for metadata/state").

The Instructor Notes convention

One Markdown file per activity, not two. Everything before a ## Instructor Notes heading is shared (self-paced-visible) content; that heading and everything after it is instructor-only. platform/api/src/content.js does the split (splitContent()) — a plain string search on that exact heading, no frontmatter parser, no template engine. The API layer (course-progress.js's shapeActivity()) only ever attaches instructor_notes to the response when the caller is resolved (server-side, from session identity) to be an instructor — never from a request parameter. A self-paced student and an instructor read the identical file; they just see different slices of it.

4. Completion rules — one source of truth per behavior

platform/api/src/course-progress.js's isActivityComplete() is the only place completion is decided, and it never invents new state for anything that's independently verifiable:

  • auto_scenario — reads progress[scenario_id].status === 'completed', the exact same progress table curriculum-rules.js already reads for stage completion (platform/api/src/ progress.js's loadProgress). A lab activity's completion is a real fact about whether the student actually solved the scenario, not a flag a course layer could get out of sync with.
  • auto_assessment — reads achievements (achievement_type IN ('assessment_completed', 'capstone_completed') with metadata->>'assessment_definition_id' matching), the exact mechanism achievements.js's recordAssessmentAchievement already writes on a real pass. No new achievement type, no new table.
  • manual_marker — the ONE new piece of state this phase adds: activity_completions (student_id, activity_id). Used ONLY for activity types with nothing else to derive completion from (a lesson has no "pass/fail," a reflection has no validator). POST /api/student/ activities/:id/complete refuses (409 not_manually_completable) for any activity whose completion_behavior isn't manual_marker — a student can never fake a lab or assessment completion through this route.

Course and module completion are pure aggregates over their activities' completion (total/ completed/pct/complete — see totals()), never separately stored.

5. Prerequisite/locking rule

A course or module lists prerequisites (an array of other course/module ids). It's locked if ANY prerequisite is not satisfied, where satisfied means: the prerequisite has zero total activities (a PLANNED entry — vacuously satisfied, since there's nothing to have completed) OR the prerequisite is fully complete. This is deliberate: without the "zero activities is vacuously satisfied" clause, an honestly-PLANNED course with no content yet would permanently lock every real course that lists it as a prerequisite, which would perversely punish honesty about what isn't built yet. See course-progress.js's prerequisitesSatisfied().

Locking is computed in two passes over each level (compute every entry's totals/prerequisite- check, THEN strip working state) rather than one — mutating and reading totals in the same pass made the result depend on Map iteration order, a real bug caught by e2e-phase6a.js during development (see that file's "found by testing" comment in course-progress.js).

6. Draft vs. published — separate from READY/PARTIAL/PLANNED

status (ready/partial/planned) describes authoring completeness and is shown to everyone, including students, honestly ("PLANNED — coming soon" is useful information, not a leak). visibility (published/draft) is a hard gate: a draft course/module/activity is excluded from every query a student-facing route runs (buildCourseTree's visClause), full stop, regardless of its status. Use draft for genuinely unfinished authoring work you don't want a student to see at all yet; use status = planned for an honest "not built yet" that's fine to be visible.

7. Why this doesn't touch Phase 1-5 systems

No column was added to scenarios/*.json, assessment_definitions, achievements, progress, or scenario_attempts. No existing API route's behavior changed (the /api/ progress/record, /api/assessments/*, /api/capstone routes are byte-for-byte unchanged). The course layer is purely additive: new tables, new routes, new frontend views, reading existing tables through their existing shapes.