Progress and certificates
Progress records how far each learner has advanced through the steps of a content and across the entire tree of modules that make up an experience. A single enrollment at the root covers that whole tree, and full completion is what unlocks the certificate.
How progress is recorded
The progress of an enrollment is a map that associates each content with the steps completed inside it — in code, something like Record<string, string[]>: the key is the content id, and the value is the list of section ids already completed. The root is just one more entry in this map, with no special handling: a single map, keyed by content id, covers the entire course.
Each section is addressed by its stable id (the title's blockId, with position as a fallback). Because progress stores stable ids rather than positions, reordering the content in the editor never corrupts the advancement already recorded: the "completed" section stays the same, whether it sits first or last.
| Enrollment field | What it stores |
|---|---|
progress | Map content id → completed section ids, including the root |
stepCompletedAt | Map content id → section id → completion moment (in ms) |
completedAt | The moment the entire tree was completed for the first time, or empty |
status | active grants access; a refunded or delinquent enrollment keeps the history but does not grant access |
One enrollment covers the entire tree
The root content is the sellable unit. The enrollment is recorded on it, and that single enrollment covers the entire recursive tree of sections and references beneath it — at any nesting depth. References (modules) are navigated continuously under the same root enrollment: there is no separate enrollment per subcontent.
Access and progress read exactly the same published tree (the organization's completion manifest), so they never disagree about the shape of the course. A content only accumulates progress if it belongs to the enrolled tree — attempting to record advancement in a content outside the tree is rejected.
- Reachability is verified by a traversal of the reference graph, safe against cycles: a set of already-visited nodes prevents infinite loops.
- A content that is referenced but never published does not count as completed (there are no completion facts for it), so it never silently closes the tree.
- The manifest is assembled once per organization and reused for all rows — no document is loaded per learner to compute progress.
Path steps
The path of a content is the ordered sequence, in document order, of its sections (titles) and the references to subcontents. Each type of step is completed in its own way:
| Type | How it is completed |
|---|---|
| Section | Marked as completed within the content itself |
| Reference (module) | Completed when the entire referenced subtree is completed — pure arithmetic over the manifest, without loading the content |
Contents with no titles still form a single step. An experience composed only of references uses the modules' completion, without creating an extra empty step.
Each step exposed to the learner carries these signals:
| Step field | Meaning |
|---|---|
done | The step is completed |
available | The release rule has been satisfied — the learner can act on it |
current | First released step not yet completed (the "continue here" highlight) |
locked | Release rule not yet met — content hidden, cannot be completed |
lockReason | Why it is locked: drip or prerequisite |
unlockAt | Drip only: the moment (in ms) when the step releases, for the countdown |
Prerequisites and release
A single evaluator decides the availability of every step, with different semantics per type:
- Sections release by prerequisite. With no explicit rule, a section follows the immediately preceding step (sequential path). An empty list means "always open." A list of ids requires that all listed steps be completed — which allows nonlinear paths.
- References (modules) release by their release rule: drip (time) plus explicit prerequisites. A module is never blocked by mere document order.
Lock reason (lockReason) | When it appears |
|---|---|
prerequisite | A required step has not yet been completed |
drip | The time-based release has not yet arrived; unlockAt carries the moment |
Time rules and prerequisite rules coexist: the learner receives only the steps available at that moment.
Drip anchor
Time-based release is measured from an anchor that the read and write paths share (a mismatch would show the step as open and still refuse completion):
- Evergreen courses release relative to the learner's enrollment date.
- Time-based courses release relative to the learner's cohort start — the entire cohort opens together. With no cohort, it falls back to the enrollment date.
A step whose drip is anchored to the previous step only starts counting once the predecessor is completed; until then it stays locked, with the countdown not yet started.
Completing a step
Marking a section as completed passes through guards in the core (not just in the interface):
- The section must be available — you cannot jump to a locked step. Unknown ids are refused.
- If the section carries a quiz, it is only completed after the learner has submitted the questionnaire (any graded attempt that touches the questions from that pool). Without a submission, completion is refused.
On completion, the moment is stamped in stepCompletedAt (which feeds the drip anchored to the previous step) and cleared on undo. Completion is recomputed over the entire tree. When that recomputation marks the tree as completed for the first time, completedAt is written — this is the trigger for both the completion email and the content.completed webhook.
Acceleration and adaptive review
Quiz performance feeds two adaptive features that lean on the same progress model:
- Review hints point to the root's direct modules whose last quiz fell below 70% — "you struggled here, worth revisiting."
- Test-out lets the learner skip a module they have proven through the quiz: with a score of
>= 80%on the last attempt, all sections of that module are marked as completed under the root enrollment, and the path advances. Sections are completed in ascending order, respecting sequential release.
Certificates
When the experience is completed, the certificate is tied to the completion and has its own page, at the content's URL within the organization (/{org}/{content}/certificate). Access requires being authenticated.
The certificate's gate is completion of the entire tree (the live rootCompleted signal), not just the root's own sections — anyone who has not yet finished is redirected back to the content. The page displays:
- the certificate of completion label;
- the learner's name and the content title;
- the issuing organization and the completion date (derived from the stored
completedAt); - actions to print, return to the content, and go to "My learning."
Notification emails
Two emails accompany the learner's journey. Both are best-effort: they are side effects that never block the main action — failures are logged, and nothing is sent when the email provider is not configured.
| When it fires | What it carries | |
|---|---|---|
| Enrollment | Right after the enrollment is created | Confirmation and a link to get started; the enrollment row is the source of truth, the email never blocks it |
| Completion | The first time the entire tree is completed (justCompleted) | Congratulations and a link to the certificate |
Restart and monitor
The team with edit rights can restart a learner's course: this erases the enrollment's entire progress map — the root and all referenced modules — clears the completion timestamps, and reopens completion. The quiz history remains, since it feeds adaptive mastery and insights.
In team monitoring, each learner appears with step-by-step progress (completed steps over the total, in the same format the learner sees) and the last quiz result, and each content carries the count of enrolled and completed learners.