Course completion & the certificate
A learner who finishes a course gets a certificate — a PDF with the MeshWeaver mark, their name, the course name and the date. This page is the model behind it: what "finished" means, exactly, where every fact lives, and what a course has to do to take part (very little).
The three signals, and their exact definitions
A course is complete when all three are full:
| Signal | Definition | Where the fact lives |
|---|---|---|
| Lessons visited | A lesson is a direct child of the course root that has a Quiz child. Visited means the page was open for EduCourseProgress.VisitDwell (3 s). |
{viewer}/_Progress/{course}/Visited/{lesson} — one idempotent marker per lesson, written by EduCourseNavigationProvider on the render turn. |
| Quizzes passed | QuizContent.Grade(sheet).Passed: every question answered and Percent >= PassPercent — inclusive, the quiz's own mark (every course declares 70). |
The learner's answer sheet at {viewer}/_Answers/{quizPath} (AnswerSpace.SheetPath), written by the quiz page's own bindings. |
| Exercises submitted | An exercise is every leaf directly under a lesson's Exercise/ folder (MyExercisesLayoutAreas.IsExerciseLeaf). Submitted means either the learner declared it done on the exercise page — the Done area's button (ExerciseDone), whose click writes the marker and then opens the solution — or they read its solution page ({lesson}/Solution/{exercise}) for the 3 s dwell. Both write the same marker, so a course that embeds the button and one that does not both complete. |
{viewer}/_Progress/{course}/Solved/{lesson}/{exercise} — one idempotent marker per exercise, written by ExerciseDone on the click or by EduCourseNavigationProvider on the render turn. |
The fold is pure: CourseCompletion.Evaluate(course, subtree, visitedSlugs, solvedKeys, sheetsByQuiz)
→ CompletionStatus with each count against the course's own total and IsComplete. Signals that
name nothing in the course shape count for nothing. A course with no quizzes has no lessons by this
rule and never completes — a certificate for nothing is worse than none.
Why the lesson rule is "has a Quiz child" and not the rail. The rail lists every page a course
root carries — a sample company, the exercises hub, type definitions, a video script; AgenticOffice's
rail has 16 entries and 4 lessons — and the lesson node type differs per course (Edu/Lesson,
Edu/Module, Edu/Page). "Every lesson ships a Quiz.json" is the authoring convention every
course already follows, so it fits the whole catalogue with no exceptions and no per-course
declaration.
Why "submitted" is the solution page. Every exercise in every course ships a solution page and ends with the link to it ("Fertig? Zur Musterlösung"); the course's own instruction is to open it after attempting the exercise. It is the one signal all three exercise families — code cells, answer books, document notes — share, and it needs no content change to record. It is evidence of reaching the solution, not a grade; a stricter per-exercise check can be added beside it later without changing the model.
The declaration, added beside the dwell. The dwell attests that the learner looked at the
answer, not that they did anything — and because any page under a lesson also counts as a lesson
visit, reading a lesson's solutions satisfies two of the three signals at once. So an exercise page
can now end with @@("area/Done"): a button beside the task it is about, which records the exercise
and opens the model solution in the same click. Do the work, say so, then compare. Nothing polices
the honest learner — the solution page stays reachable by URL, exactly as before — but nothing is
counted by scrolling past either.
It is added, not substituted. The dwell still writes the same marker, so no course changes behaviour by upgrading Edu; a course opts in by embedding the area. Making the declaration the only path, and confining the lesson visit to the lesson page itself, would change what every course's ✓ ticks and counts mean — a fleet-wide decision, deliberately not taken here (Plugins#1449).
Where everything lives — the learner's own space
Every fact above is in the learner's partition, under the partition ACL they already hold. Nothing is
written into the course (GitSynced, rewritten on every sync) and no grant is minted anywhere. The
markers follow Edu/DataBoundExercises' rule for learner state: child nodes, one per fact, each a
self-describing idempotent upsert — never an accumulating map.
{viewer}/_Progress/{course} — the resume record (where did I stop)
{viewer}/_Progress/{course}/Visited/{lesson}
{viewer}/_Progress/{course}/Solved/{lesson}/{exercise}
{viewer}/_Progress/{course}/Certificate — issued once, never touched again
{viewer}/_Answers/{course}/{lesson}/Quiz — the quiz answer sheet
Every read of these is stamped with the viewer (InstallRecord.ViewerQuery): an unstamped read
evaluates as Anonymous and answers EMPTY, and "no markers" and "nothing done" are the same answer.
The certificate node
CourseCertificate.Node(...) builds it; CourseCertificateStore.Issue writes it — create-only.
A certificate is a record of a moment (name, course, date, a verification id) and re-issuing would
re-date it, so a second issue keeps the first: the AnswerSheetStore.Ensure shape (existence probe
on the query's own snapshot frame, a refused create re-probes), bounded by the pure seam
CourseCertificate.IsCertificatePath — a usable home, that one path, nothing else. System identity
is never pointed at a path a caller chose.
It is a core Markdown node with a MarkdownContent body plus the facts as members
(course, courseName, learner, viewer, issuedAt UTC, issueDate, language,
verificationId, the scope counts). Core Markdown declares the document export, so the platform's
PDF dialog works on it with no export code in Edu.
🚨 The body is ONE raw-HTML line — the MeshWeaver mark inline as SVG, no heading, no image. The
content-faithful PDF pipeline prints raw HTML verbatim (its sanitizer keeps svg and style),
prints a markdown image as a placeholder, and puts a contents page in front of any heading. So the
logo is CourseCertificate.LogoSvg inlined, and a blank line is never emitted (it would end the
HTML block). With no brand configured the PDF is the body and nothing else; a deployment with a
CorporateIdentity gains its cover page, which the dialog's cover toggle removes.
The certificate speaks the course's language (CertificateTexts.For(root.language) — de-CH →
«Zertifikat», anything else English): it names the course and is kept and shared as-is. The panel
around it is chrome and follows the viewer's language (CourseInviteTexts), like every other
Edu surface.
The learner's name is their profile's fullName → displayName → name, read in whatever shape the
{viewer} node's content arrives; then the access context's name; then the home id. The date is the
UTC instant rendered in the viewer's zone on the render turn (ToDisplayTime), written out from a
month table — «3. September 2026».
The panel — where the learner sees it
CourseCompletionLayoutAreas renders «Lektionen 4/4 · Quiz 4/4 · Übungen 12/12» and, once
complete, «Kurs abgeschlossen», the link to the certificate and one line saying where to save it.
It lives on the
course-level Edu/CourseInvite host — {course}/MyExercises, whose parent is the course root —
in two places:
- inside that host's own exercises grid (the
Exercisesarea, i.e./{course}/MyExercises); - as the standalone
Completionarea, which a course page embeds:
@@("../MyExercises/area/Completion")
A per-lesson host reports one lesson and claims nothing; a host inside the learner's own copy claims
nothing either. The panel issues the certificate itself — once, when the viewer is entitled, the
status is complete and no certificate exists — prepared on the render turn, bounded by
CourseCertificateStore.IssueBudget, its failure reported through a data stream, never a throw.
The panel embeds no layout area — it links. It used to place the Export module's ExportPdf
area for the certificate node, gated on the presence of the Export package root. That gate asks
the wrong question: the area is registered by the module's ConfigureDefaultNodeHub, so it exists
only where the module ASSEMBLY is loaded, while the package's content installs independently of it.
On a self-registry install the content lands and the module never can (MeshWeaver#2417) — so the
probe said "available", the embed resolved nothing, and the learner read the platform's raw "No
renderer is registered for area ExportPdf" under their certificate (measured on memex.localhost,
2026-09-06).
Nothing in the mesh answers "can that area render on that hub": LayoutDefinition.AreaDefinitions
is internal, and the one cross-hub answer (GetLayoutAreasRequest, handled by every node hub)
would be the first request/response ever issued from a compiled node source. Until the platform
exposes a public HasArea, the panel links and the certificate page carries its own export — the
node's Export menu item where the module is loaded, the browser's print dialog everywhere. One
click, and nothing that can fail to resolve. The PDF itself needs the headless browser the
portal-ai image carries.
Rolling it out to a course — the recipe
The model is one Edu feature; a course takes part by convention:
- Type its lesson and solution pages with an Edu type (
Edu/Lesson,Edu/ModuleorEdu/Page). The markers are written by the Edu navigation provider, which core's plainMarkdownpages do not carry — an untyped lesson is unread forever. Every course but AgenticOffice already did; AgenticOffice addsNodeType: Edu/Pageto its 4 lessons and 12 solutions. 1b. Recommended — end every exercise page with the declaration:@@("area/Done"), where the «Fertig? Zur Musterlösung» link used to be. The learner records the exercise and reaches the solution in one click, and the marker then means what it says. A course that does not embed it still completes on the solution-page dwell. - Give every quiz question a stable
key(Edu/DataBoundExercises). Answers are stored by key; without one they are stored by position, and inserting a question re-maps every later answer. - Embed the panel on the course's exercises page:
@@("../MyExercises/area/Completion"). - Optionally declare the course's
languageon its root — it decides the certificate's language.
Nothing else: no per-course configuration, no course-local type, no install-path change.
What is deliberately NOT here
- A grade for exercises. The signal is "read the solution", or the learner's own declaration; the model's counts and the certificate's scope line say so. A per-exercise check (a code battery stamped green, a target document no longer pristine) can gate the button without changing the paths.
- The tightened signals. Making the declaration the only way an exercise counts, and the lesson page the only page that counts as reading the lesson, alters every existing course's ticks and counts. That is a fleet-wide change, tracked as Plugins#1449; this page describes the additive half, which is inert until a course embeds the area.
- A tile stamp.
CourseAppTilestill counts opened pages; a "✓ complete" on the home tile is a follow-up. - Verification from outside. The verification id is printed on the certificate and stored on the node; a lookup surface is a follow-up.
Tests: Edu/CourseInvite's Tests area (CourseCompletionTests, CourseCertificateTests, incl. three
live cases proving the write, the create-only rule and the stamped reads), Edu/Module's
(EduCourseNavigationTests — the solved markers and the write chain), Edu/Quiz's (the inclusive
mark). Locally: python3 devtools/run-node-tests.py Edu.