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]>
167 lines
11 KiB
Markdown
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.
|