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 >= PassPercentinclusive, 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 fullNamedisplayNamename, 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:

@@("../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:

  1. Type its lesson and solution pages with an Edu type (Edu/Lesson, Edu/Module or Edu/Page). The markers are written by the Edu navigation provider, which core's plain Markdown pages do not carry — an untyped lesson is unread forever. Every course but AgenticOffice already did; AgenticOffice adds NodeType: Edu/Page to 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.
  2. 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.
  3. Embed the panel on the course's exercises page: @@("../MyExercises/area/Completion").
  4. Optionally declare the course's language on 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

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.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.