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]>
11 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— readsprogress[scenario_id].status === 'completed', the exact sameprogresstablecurriculum-rules.jsalready reads for stage completion (platform/api/src/ progress.js'sloadProgress). 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— readsachievements(achievement_type IN ('assessment_completed', 'capstone_completed')withmetadata->>'assessment_definition_id'matching), the exact mechanismachievements.js'srecordAssessmentAchievementalready 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/completerefuses (409not_manually_completable) for any activity whosecompletion_behaviorisn'tmanual_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 samebuildCourseTree()lock computation every other route trusts, and refuses (403locked) if either is locked for that student — a real, pre-existing gap found and fixed while proving the ROOT 01-04 prerequisite chain; seedocs/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, changingcompletion_behavior, adding/removing/reordering activities in a way that changes what "complete" means) SHOULD bumpversionon the affected row(s) — see023_phase6b_curriculum.sql's ownversion = version + 1on every row it semantically changes (resequencing, prerequisite changes,is_requiredchanges). - 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. Amanual_markercompletion (activity_completions) is keyed byactivity_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 inprogress. - Scenario references across versions: a
labactivity'sscenario_idis just a string reference intoscenarios/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 ofcurriculum.js's cache). If an activity'sscenario_iditself is changed to point at a DIFFERENT scenario, that's a semantic edit (bump the version) and — depending on intent — may warrant a newactivity_idinstead 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.