Files
rootnode-academy/docs/COURSE_CONTENT_MODEL.md
T
ced4568andClaude Sonnet 5 a09b7aaa53 fix(curriculum): enforce course/module lock on manual-marker completion
POST /api/student/activities/:activityId/complete previously checked only that the activity
exists and the student holds an active PROGRAM enrollment — never whether the activity's own
course/module was actually unlocked for that student. A direct POST could mark any
manual_marker activity (lesson/demonstration/knowledge_check/guided_exercise/reflection)
complete regardless of the real prerequisite chain, the exact class of gap Phase 6B already
fixed once for the capstone's own start route, just never closed for this more general one.

Found while proving the real ROOT 01->02->03->04->05 chain via direct API (this phase's own
explicit requirement) — and confirmed to predate Phase 6E entirely by reproducing it against an
unrelated, pre-existing ROOT 05/06 pair before touching any Phase 6E content.

Fix: the route now resolves the activity's own course/module through the exact same
buildCourseTree() lock computation every other route already trusts, and returns 403 { error:
'locked' } if either is locked — reusing the existing mechanism, not a second, duplicated check.
Verified live: both a ROOT 02 bypass attempt and a ROOT 06 bypass attempt are now correctly
rejected; legitimate unlocked completions are unaffected.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 17:08:28 -05:00

167 lines
11 KiB
Markdown

# 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. **(Phase 6E)** the route also resolves the activity's own
course/module through the same `buildCourseTree()` lock computation every other route trusts,
and refuses (403 `locked`) if either is locked for that student — a real, pre-existing gap
found and fixed while proving the ROOT 01-04 prerequisite chain; see
`docs/PHASE6E_ROOT_FOUNDATIONS_COMPLETION.md`.
Course and module completion are pure aggregates over their activities' completion (`total`/
`completed`/`pct`/`complete` — see `totals()`), never separately stored.
### Optional vs. required activities (Phase 6B)
`module_activities.is_required` (defaults `true`) lets a module distinguish CORE teaching from
GUIDED/INDEPENDENT-practice duplicates and ADVANCED/optional extras — see
`docs/ROOT_CURRICULUM_COVERAGE_MATRIX.md`'s classification scheme, used throughout ROOT 09. A
module's `complete` flag is true once every REQUIRED activity is done; an incomplete optional
activity never blocks it, though `total`/`pct` still count every activity, required or not.
Every Phase 6A activity has `is_required = true` (the column's default) — this is purely
additive, nothing that was complete before Phase 6B becomes incomplete now.
## 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.
## 6b. Content versioning (Phase 6B §21)
Follows the exact same convention `assessment_definitions`/`programs` already established — a
plain incrementing `version` integer, bumped by hand, informational rather than enforced:
- **A typo-only edit** (fixing a spelling mistake, a broken link, rewording a sentence without
changing its meaning) does NOT require a version bump — nothing about what a student is being
taught or held to changed.
- **A semantic edit** (changing a learning objective, swapping a lab's `scenario_id`, changing
`completion_behavior`, adding/removing/reordering activities in a way that changes what
"complete" means) SHOULD bump `version` on the affected row(s) — see
`023_phase6b_curriculum.sql`'s own `version = version + 1` on every row it semantically
changes (resequencing, prerequisite changes, `is_required` changes).
- **What happens to existing completions**: nothing, automatically. Unlike an assessment
session (which snapshots the definition version it was drawn against, because a session is a
point-in-time timed attempt), a course/module/activity's completion is derived LIVE every time
it's read (`course-progress.js`) from the CURRENT row plus the student's progress/achievements/
activity_completions — there is no snapshot to invalidate. A `manual_marker` completion
(`activity_completions`) is keyed by `activity_id`, which is stable across a version bump
(the id doesn't change, only its content/metadata does) — so a student's prior "I did this"
record stays valid even after a semantic edit, exactly like editing a scenario's own
Markdown description doesn't retroactively un-complete it in `progress`.
- **Scenario references across versions**: a `lab` activity's `scenario_id` is just a string
reference into `scenarios/data/*.json` — if that scenario file itself changes, the activity
automatically reflects the new content next time it's loaded (same as every other consumer of
`curriculum.js`'s cache). If an activity's `scenario_id` itself is changed to point at a
DIFFERENT scenario, that's a semantic edit (bump the version) and — depending on intent — may
warrant a new `activity_id` instead of mutating the existing one, so a student's prior
completion of the OLD scenario isn't silently reinterpreted as completion of the new one.
- **What this deliberately does NOT build**: no content-revision history table, no diffing, no
"students who saw v1 vs v2" cohort tracking. If a future phase needs that level of rigor
(e.g. for compliance/audit reasons), it's a real, separate feature — not something to bolt
onto a plain integer column.
## 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.