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/ folder — index.* 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/InstallManifestnode — 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 byInstallRecordTests.LegacyEduRecord_IsStillRecognisedAsInstalledandInstallDirectivesTests.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:
- Theory/ — Markdown children, embedded in order (edit one and its embed re-renders).
- Example/ — worked-example Code children, embedded in order.
- Exercise/ —
Edu/Exercisechildren; see Exercises live in the trainee's space below.
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:
- Anonymous — a sign-in hint (no exercise content).
- Signed in, not installed — the clear message plus the course root's own embedded install
step (
InstallCta, which raises the install dialog). Module pages carry no install machinery of their own. - Installed — one card per exercise linking into the trainee's own copy ("Open my exercise →"); an exercise whose copy is missing gets the install pointer instead of a dead redirect. Starting fresh is the dialog's Reset to defaults — the same standard surface every plugin has.
- Own space — a viewer browsing a course inside their own partition (an author's template, or the copies themselves) sees the exercise workspaces embedded directly.
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, soLegacyExerciseWorkspace(ModuleSource/) 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
Quizchild) 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_Progressspace, folded by the pureCourseCompletion.Evaluate. The course-levelEdu/CourseInvitehost shows the counts and, once complete, issues the certificate (a coreMarkdownnode 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 (ExerciseConfiguration → ExerciseContent) 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)