Education plugin — how the types work

The Edu plugin ships the course building blocks as dynamic node types — their C# lives under each type's Source/ and the mesh compiles it live on install. No app rebuild, no NuGet.

Install is the STORE's — Edu has no installer

A course ROOT is a Store/Plugin node. It DECLARES what installs, in its own content:

// {Course}/index.json — nodeType "Store/Plugin"
"content": {
  "$type": "PluginContent",
  "repo": "Systemorph/MeshWeaver.Education",
  "price": 900,
  "installPaths": [                       // ← the declaration
    "AgenticEngineering/TDD/Exercise",
    "AgenticEngineering/TDD/Solution",
    "AgenticEngineering/ReinsuranceDemo"
  ]
}

🚨 Every declared installPath MUST be a NODE — a bare folder is not one

A folder is addressable only when a node file backs it. For a child node that is Solution.md/Solution.json beside the Solution/ folderindex.* is the partition root's form, not a general one (an import also accepts Solution/index.md, which is what a GitSync export writes, but do not author it that way). A folder with neither — just Solution/A.md, Solution/B.md — is not a node, so declaring it in installPaths declares something that does not exist.

The install then fails every single time, and the message actively misleads:

Verify install failed: Install did not persist '{viewer}/{Course}/{Lesson}/Solution' —
the copy was accepted but not stored (a transient hub error?).
No manifest was written; please retry the install.

That reads as flaky infrastructure. It is not: there is nothing to copy, so VerifyInstalled can never pass and retrying can never work. The manifest is written last, on purpose, so the learner is never recorded as installed — they are simply stuck.

Nothing reconciles the two sides: installPaths is a hand-written list in the root, the node layout is on disk, and no compiler, test or type checks one against the other. Both AgenticPrimer and AgenticPrimerDe shipped six {Lesson}/Solution folders with no index.md (2026-08-04) and every install of either course failed until the folder nodes were added.

So, when you add a path to installPaths:

# every declared root must resolve to a node file — EITHER accepted form
test -f "$p.md" || test -f "$p.json" || test -f "$p/index.md" || test -f "$p/index.json" \
  || echo "NOT A NODE: $p"

Systemorph/MeshWeaver.Education enforces exactly this in scripts/validate-repos.py (validate_install_paths), in its blocking CI job. Copy that rule into any repo that ships installPaths — it costs about a second and catches the whole class before it reaches a learner.

⚠️ A blocking install e2e that exercises only ONE course will not catch this. The education repo's gate ran E2E_COURSE: AgenticEngineering, whose paths were all intact, so the primers' broken install was invisible to the only job that can fail a PR.

InstallPlan.FromContent(content, coursePath) (Store) turns each declared root into a Nodes install directive — plugin-relative, normalized (trimmed, deduped, foreign paths dropped, nested roots collapsed) — alongside the standard fixed Skills ({course}/Skill{viewer}/Skill) and Agents directives. The Store's Localizer then executes that plan: provision the target partition, ensure the home root, copy each declared root (idempotent overwrite), verify every copy landed, and only then write the install record. One declaration, one engine, one record.

The surfaces are the Store/Plugin type's own — there is no Edu install page, and there are no dedicated install pages at all (/{course}/Install, /{course}/Reinstall and /{course}/Uninstall are retired areas; a link to one renders "Area not found"):

Surface What it is
/{course} (the cover) THE install surface: the hero's Get / Repair step raises the install dialog in place (destination picker, additive repair, Reset to defaults, Uninstall — copies only, never the entitlement)
/{course}/Subscribe the paywall: price, conditions, coupon, Stripe checkout — and every successful acquisition installs by itself, a redemption on the click and a card payment from the fulfilment chain (OrderFulfilment, so it runs whatever the buyer's browser is doing): the course's requires closure leaves-first, then the course into {viewer}/{courseId} (DependencyInstall.InstallAfterAcquisition)

Entitlement is a record, checked by the Store: {course}/_Entitlements/{viewer} (the audit marker a coupon / order / admin grant writes) OR {course}/_Access/{viewer}_Access (the enrollment grant). Never a permission probe.

The install record lives at {viewer}/_Install/{repo-slug} — a Store/Install node listing {item, purchasedAt, installedAt, installedPath}. Written LAST, after every copy is verified, so it never claims a partial install. Uninstall clears installedPath/installedAt and KEEPS purchasedAt.

Legacy records. Before the consolidation Edu had its own installer writing an Edu/InstallManifest node — at the same path with the same JSON. Every reader (Store and Edu alike) reads that record by shape, never by node type, so an existing learner's install still resolves and their copies are never orphaned; the next Store write re-types the node in place. Pinned by InstallRecordTests.LegacyEduRecord_IsStillRecognisedAsInstalled and InstallDirectivesTests.LegacyEduManifest_MaterializesAsAStoreRecord.

Edu/CourseInvite — the "your exercises" grid host

All that is left of the old invite type: a tiny node whose area lists the viewer's OWN installed exercise copies as difficulty-graded cards. Module pages are plain Markdown, and a plugin type's area only resolves on instances of that type — so each module hosts one at {course}/{module}/MyExercises and embeds @@("MyExercises/area/Exercises") cross-node (the course-level catalog host at {course}/Exercises/Grid embeds the MyExercises alias of the same area). The area reads the Store's install record to find the copies and writes nothing; when nothing is installed it points at the course COVER (InstallRecord.InstallHref), whose hero carries the install step — never at a named install page, which no longer exists.

The course-level host ({course}/MyExercises, whose parent is the course root) also carries the completion panel — «Lektionen 4/4 · Quiz 4/4 · Übungen 12/12», then the certificate and its PDF dialog — inside its grid and as the standalone Completion area a course page embeds: @@("../MyExercises/area/Completion"). See Course completion & the certificate.

Edu/Course

The root of an interactive course. Its content (CourseContent) carries a Description (markdown, shown on the overview) and TutorInstructions (read by an AI tutor, never shown to trainees).

The Content view renders the description plus a card per module — the course's child Edu/Module nodes, ordered by Order, each linking into the module page. (The course reads each module's summary off the node JSON, so Edu/Course stays self-contained.)

Edu/Module

One module of a course. Its content (ModuleContent) carries a Summary.

The Content view renders the summary, then embeds the module's children live:

plus Prev/Next navigation across the course's sibling modules.

Exercises live in the trainee's space

Exercises are done on personal copies — the templates are never shown to trainees. Where the copies live comes from the Store's install record ({viewer}/_Install/{repo-slug}), which is authoritative: the viewer picked the namespace at install time, so it is not derivable. Each module page's Exercises section renders by state:

Legacy workspaces. A retired one-click flow copied exercises to a fixed {viewer}/_Courses/{course}. Nothing writes one any more, but learners who used it still have one, so LegacyExerciseWorkspace (Module Source/) still DETECTS it — pure path arithmetic plus the marker shape — and the module page links those copies as a fallback. Re-installing through the cover's install dialog produces a recorded, current copy, which the module page prefers.

Where the learner left off — Resume

A lesson page remembers the visit, and the course cover offers ▶ Resume beside Start.

the record {viewer}/_Progress/{courseId} — a plain Markdown node carrying course, lastPath, lastVisitedAt
written by ModuleLayoutAreas.RememberVisit, from the lesson page
read by CourseProgress on Store/Plugin — the course COVER is a plugin node, so the button is planned there

In the learner's own home, never beside the lesson. A course partition is gated and system-synced and a learner holds Viewer there, so a progress write next to the lesson is denied, logged as a warning, and silently lost.

🚨 The write is hung off nothing. A lesson page re-renders on every emission of an eight-arm CombineLatest; a write on that stream would produce dozens of node versions per page load, each feeding the reader's own change feed. Everything the write needs — the viewer, the course, this module — is known synchronously at render time, so it is built there (which is also what stamps the learner's identity on it: CreateOrUpdateNode captures the ambient AccessContext when it is called) and then guarded twice: a once-per-rendered-area claim, taken before the write is subscribed, and a dwell delay (ProgressDwell, 3 s) so a page a navigation merely passed through never records. The record is a complete node, so the write is an idempotent upsert — there is no read-modify-write and therefore no lost-update race.

🚨 A course spans two trees. Lessons live in the catalogue ({course}/…), the exercises a learner works on live in their own space ({viewer}/{course}/…). lastPath alternates between them as a learner moves lesson → exercise → lesson, so BelongsToCourse accepts both; a catalogue-only check would call every exercise page stale and send someone who stopped mid-exercise back to lesson 1.

Resume never replaces Start. With a record the cover leads with Resume (accented, naming the page) and keeps Start as the quiet second button; with no record the row is byte-for-byte what it always was.

The completion model (Edu 1.6). A course is complete when every lesson (a direct child of the root with a Quiz child) was visited, every quiz passed at its own inclusive mark, and every exercise's solution page was read — three sets of markers in the learner's own _Progress space, folded by the pure CourseCompletion.Evaluate. The course-level Edu/CourseInvite host shows the counts and, once complete, issues the certificate (a core Markdown node at {viewer}/_Progress/{course}/Certificate, create-only) with the platform's PDF dialog. Design, paths and the per-course recipe: Course completion & the certificate.

Edu/Exercise

A "your turn" task. Its content (ExerciseConfigurationExerciseContent) carries the Statement (markdown), a Difficulty (1 = easiest), the Language of its code artifacts, and — on a personal copy only — SourcePath, the template the copy was installed from.

🚨 An exercise whose body embeds a workbook or a quiz is NOT redirected to the learner's copy (ExerciseLayoutAreas.KeepsAnswersCentral). Those answers are attached to the CENTRAL node, and AnswerSpace.SheetPath refuses a workbook already inside the viewer's own space — so a redirect would land the learner on a page that renders the exercise read-only. Every other exercise routes to the copy exactly as before; a runnable code cell still needs one.

The Workspace view renders the statement and embeds the starter (Source/Starter, a Code node) live. On a personal copy (SourcePath set) it opens with a "← Take me back to the lesson" bar that navigates back to the module in the main space; the copy itself is fully the trainee's to change. The validate loop (Validate → pass/fail) belongs to the Edu/ExerciseAttempt type and its validation control plane — migrated as a follow-up increment.

Edu/Quiz

A multiple-choice chapter quiz, and the second authored type over the data-bound seam below: the questions are ONE central node; each learner's picks are fields on their own answer sheet at {viewer}/_Answers/{quizPath}.

Its content (QuizContent) carries an optional Description, a PassPercent (default 70, display only), and an array of QuizQuestion (Key, Prompt, Options, zero-based CorrectIndex, Explanation).

The Quiz view shows everything answered plus the next question — the progression is DERIVED from the sheet, so a learner who closes the tab comes back exactly where they stopped — with instant right/wrong feedback and the explanation, and a live score once every question has a response. Every option is an ordinary RadioGroupControl bound to the sheet, so a pick is written per field by the learner's own browser under their own identity; the page is framework controls throughout. An anonymous reader sees the questions and the options as a plain list, with nothing marking the answer. Embed it in a lesson/module page with @@("Quiz/area/Quiz").

🚨 key is permanent, and prompt is free — the same rule as a workbook field, because it is the same pointer on the same kind of sheet. A question with no key is addressed by POSITION (q1, q2, …), so inserting a question moves every later answer; the page prints that as an authoring problem, along with a correctIndex outside the options, two identical options (a response is stored as the option's TEXT), a duplicate key, a blank option and a missing prompt. Every one of those would otherwise be silent.

There is no Retry button: the picks stay live, so changing one re-scores immediately, and nothing of the learner's is ever deleted to start again.

Edu/Workbook + Edu/AnswerSheet — the data-bound exercise

Authored text stays central; the learner's state is theirs. A Edu/Workbook holds the questions and nothing else, in the course partition, read by every learner — so a GitSync of the course IS the update, for everyone, with nothing delivered per learner. Each answer box on its page is bound to the learner's own Edu/AnswerSheet at {viewer}/_Answers/{workbookPath}, per field, written by their own browser under their own identity: no /data replica, no Save button.

The sheet lives in the learner's home partition on purpose — a course space is rewritten from main on every sync, so answers stored beside the exercise would be destroyed by the next authoring change, silently. AnswerSpace.SheetPath refuses to return any path inside the authored partition, which is what makes that structural rather than remembered.

Embed a workbook in a lesson exactly as you embed a quiz: @@("Wishes/area/Workbook").

🚨 A field's key is the permanent identity of the answer it collects — reword the prompt freely, rename the key never. Full reference: Data-bound exercises.

Authoring a course

MyCourse                     nodeType Edu/Course   — Description
  Intro                      nodeType Edu/Module   — Summary
    Theory/Welcome           Markdown
    Example/Hello            Code
    Exercise/First           nodeType Edu/Exercise — Statement
      Source/Starter         Code (the trainee's starting point)
      Test/Validation        Code (the spec)
      Solution/Solution      Code (reference solution)
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.