Data-bound exercises
Authored text stays central. The learner's state is theirs.
An exercise is one authored node that every learner reads, plus one small state node per learner. Never a copy of the exercise per learner. Everything else on this page follows from that one sentence.
| Node | Where it lives | Who writes it | |
|---|---|---|---|
| Authored | Edu/Workbook ยท Edu/Quiz |
the course partition, e.g. ThinkInStreams/L1/Exercise/Wishes |
GitSync, from the repo |
| Per learner | Edu/AnswerSheet |
{viewer}/_Answers/{authoredPath} |
the learner's own browser, one field at a time |
One sheet type serves every authored kind. A workbook's sheet and a quiz's sheet are the same
node type at the same kind of path โ keyed by the AUTHORED node's path, so they can never collide,
and a learner's answers to everything sit together under {viewer}/_Answers/.
Why the split is not a preference
The pattern this replaces kept both halves in one node inside a per-learner copy of the course:
the author's prompts and the learner's answer1โฆ6, side by side. Three costs followed, and all
three dissolve here:
- Editing the prompts meant writing the answers node. Now they are different nodes with different owners.
- A restore could not tell them apart. A node that mixes authored and learner content is classifiable only as a whole, so repairing stale authored text risked clobbering work.
- Every authored fix had to be delivered N times, once per copy, through an updates-pending offer. Now a GitSync of the course is the update โ for everyone, at once, with nothing to deliver and nothing that can be missed.
๐จ Where learner state lives, and why the sync cannot destroy it
A GitSynced space is rewritten from main on every sync. Anything written into it out of band
survives until the next import and then silently reverts. So a learner's answers stored beside the
exercise would be destroyed by the next authoring change โ with no error, no warning, and no symptom
until the learner comes back to an empty box.
That is why the sheet lives under the learner's own home partition, as a _-satellite beside the
ones the platform already keeps there (_Billing, _Orders, _Progress, _App):
rbuergi/_Answers/ThinkInStreams/01-TheCombinatorialTrap/Exercise/Wishes
โโโโฌโโโ โโโโฌโโโโ โโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
home container the authored path, carried whole
The authored path is carried whole rather than flattened, so the mapping is invertible: a sheet says exactly which exercise it answers, with no escaping and no lookup table.
And it is a satellite of the home, not a child of the learner's course copy. The old install
model still writes {viewer}/{course}/โฆ for courses that have not moved yet, and that subtree is
walked by the install verifier and classified by RepairPlan/ContentFingerprint. Answers parked
inside it would be indistinguishable from copied authored nodes to exactly the machinery this design
exists to retire. _Answers sits beside that tree, never in it, so a repair or an uninstall of the
old copy cannot reach a learner's work.
And it is enforced, not documented. AnswerSpace.SheetPath(viewer, workbook) is a pure function
that REFUSES to return a path inside the authored partition โ so the only bind target the page can
ever be handed is one the sync cannot reach. It also refuses an anonymous, system or hub principal,
a viewer home that is not a single partition segment, a malformed path, and a workbook that already
sits inside the viewer's own space. Every one of those is a case in
Edu/AnswerSheet/Test/AnswerSpaceTests.cs.
Access: no grant is minted, and none is needed
The sheet is a node in the learner's own partition, with MainNode set to their home โ so the
satellite access rule delegates its access to the grant they already hold there. Nothing in this
feature writes an AccessAssignment or a _Policy, and nothing should: a grant a feature invents
is a grant nobody asked for. One learner cannot read another's sheet for the same reason they cannot
read another's billing profile.
How a box saves
Each answer box is an ordinary framework control. Its DataContext is
LayoutAreaReference.GetMeshNodeDataContext(sheetPath) and its pointer is answers/{key}, so the
GUI binds it straight to the learner's sheet and writes each edit back per field, through
MeshNodeBindingExtensions โ a read-modify-write that touches only that key.
- No
/datareplica, no debounced save subscription, no Save button โDoc/GUI/DataBinding's ABSOLUTE section. - The write runs in the learner's own browser circuit, under their own identity. A control bound
to the central node would write the course itself, server-side, past the reader's own rights โ
both a privilege hole and a change the next sync erases.
WorkbookTestsasserts the decoded bind target is the sheet and not the workbook. - The one server-side write is the CREATE of an empty sheet, prepared on the render turn so it
carries the learner's identity, and create-only, never an upsert: a second render must not
overwrite what the learner has typed. That invariant is executed live, on a real mesh, in
AnswerSheetTests.Live_ASecondEnsureKeepsTheLearnersAnswers.
Authoring one
A workbook is a node in the course, typed Edu/Workbook:
{
"$type": "MeshNode",
"id": "Wishes",
"namespace": "ThinkInStreams/01-TheCombinatorialTrap/Exercise",
"path": "ThinkInStreams/01-TheCombinatorialTrap/Exercise/Wishes",
"name": "Your wish book",
"nodeType": "Edu/Workbook",
"content": {
"$type": "WorkbookContent",
"intro": "Three things you wish the machine would do for you.",
"fields": [
{ "key": "wish1", "prompt": "The first wish", "rows": 3 },
{ "key": "wish2", "prompt": "The second", "rows": 3 },
{ "key": "why", "prompt": "Why those two and not others?", "rows": 4 }
],
"closing": "Come back to this at the end of the course."
}
}
A lesson embeds it exactly as it embeds a quiz: @@("Wishes/area/Workbook"). Opened on its own it
renders with the whole-course index, like every other page in the course.
๐จ key is permanent; prompt is free
The key becomes the JSON pointer answers/{key} on every learner's sheet, so renaming it orphans
every answer already given. Reword the prompt as often as you like; change the key never.
Keys are identifiers โ letters, digits, - and _, starting with a letter, at most 64 characters.
Two authoring mistakes would otherwise be silent, so the page prints them instead of dropping the
field quietly:
- a key carrying
/or a JSON-pointer escape would address a nested object nobody reads; - two fields sharing a key would share one answer, so the second box would overwrite the first.
Quizzes are the same two halves
Edu/Quiz is the second authored type over this seam. Its questions are one central node; each
learner's picks are fields on their own answer sheet, at {viewer}/_Answers/{quizPath}.
Before this, a quiz kept its answers in a JavaScript variable in a hand-injected HTML runner:
a reload, a tab switch or a dropped connection wiped the run, the score existed for exactly as long
as the page did, and no learner could ever come back to see what they had answered. Every option is
now an ordinary RadioGroupControl bound to the sheet โ so a pick is written per field by the
learner's own browser, and the whole page is framework controls rather than markup we wrote.
{
"$type": "MeshNode",
"id": "Quiz",
"path": "ThinkInStreams/01-TheCombinatorialTrap/Quiz",
"name": "Chapter check",
"nodeType": "Edu/Quiz",
"content": {
"$type": "QuizContent",
"description": "Three questions on what you just read.",
"passPercent": 70,
"questions": [
{
"key": "backpressure",
"prompt": "What does a slow subscriber do to a fast producer?",
"options": ["Nothing โ frames are dropped", "It applies backpressure", "It throws"],
"correctIndex": 1,
"explanation": "The producer is asked to slow down rather than the frames being lost."
}
]
}
}
A quiz question's key is as permanent as a workbook field's โ it is the same JSON pointer on
the same kind of sheet. A question with no key falls back to its POSITION (q1, q2, โฆ), which
still works but moves every later answer the moment a question is inserted; the page says so. The
key is also the only label the learner sees for that answer on their own sheet, which carries no
authored text at all, so backpressure reads far better there than q4.
A response is stored as the option's text, not its index โ self-describing on the sheet, which is why two identical options in one question are an authoring error the page prints.
Three things follow from the answers being persisted, and each is a deliberate choice:
- One question at a time, without a circuit to remember it. The page shows everything answered plus the next one, derived from the sheet โ so a learner who closes the tab comes back exactly where they stopped.
- The score is live. Change any answer and it re-scores. There is no Retry button, because a reset would mean deleting the learner's work to make the page feel new.
- The page reads the sheet server-side, STAMPED with the viewer. A page whose content depends on
the answers has to read them;
AnswerSheetStore.Watchis that read, and it carries the viewer's id for the same reason the existence probe does โ an unstampedMeshQueryRequestevaluates asAnonymous, whose RLS drops every node in a private space, so it would answer EMPTY rather than failing and every question would read as unanswered forever. Nothing on that path writes an answer.
Pages are templates: the page first, the data after
Every Edu page follows the platform rule (core Doc/GUI/DataBinding โ Templates first, data
later): a layout area emits its whole control tree at once and binds its values, rather than
waiting for the node or a query and building controls out of what came back. Until a value arrives
the control draws the platform's loading shape (DefaultViews/LoadingShape); every later change
reaches the same control.
The shape used across Edu is template + feed:
- the template is built from controls whose values are
JsonPointerReferences into a data slot (LayoutAreaReference.GetDataPointer(id)), and an optional part hides through a style pointer; - the feed is started in
WithBuildup(so it lives exactly as long as the rendered area), reads the node or the query, and writes a small view record into the slot withUpdateData; - a list is an
ItemTemplateControlover a slot holding rows โ never one control per row built on the hub.
Converted: the answer sheet page (AnswerSheet ยท MyAnswers), the exercise card
(Exercise ยท ExerciseCard), the completion panel (CourseInvite ยท Completion) and the course
catalog with each card's "Open my copy" link (CourseCatalog ยท Catalog). Each has a Test/ case
proving its template holds no deferred view.
Not converted, on purpose: the exercise Overview reads only to choose a ROUTE (redirect to the
learner's copy, or the framework's own bound Overview). Nothing is left on the follow-up list:
the pages below whose structure follows the data are templates too.
Pages whose structure follows the data
Two pages had structure that depended on the data, so a mechanical conversion was not enough. Each one was redesigned with the same three tools, in this order of preference:
- Every variant is in the template, and a bound style shows one. Use this when the set of variants is known in advance.
- A bound list of uniform rows. Use this when the count is data but every row has the same shape.
- A nested slot with the skeleton (
LayoutAreaControl+SpinnerType.Skeleton), used only for a part that is genuinely computed. The page around the slot still renders at once.
The roadmap (LearningJourney ยท Assessment):
- The questionnaire and the roadmap are both in the first frame. All six questions are present.
- Each choice is drawn twice, once ticked and once plain, and the projection shows the right one. So a click handler never depends on what the plan currently says.
- The heroes, the saved legacy answers, the "pick up where you left off" panel and the summary are toggled or bound by pointer.
- "Your other courses" is a list of rows.
- Two parts stay computed: the course stops and the notes from an earlier plan. Each carries a
notes field bound to the plan node at
courses/{key}/notes, and{key}is a per-course GUID. A bound row cannot carry a node pointer that depends on the row, so these two render in nested slots (RoadmapStops,RoadmapEarlier). - The tile binds the project straight to the plan node.
- The entry-point branch still waits on the install query, because it chooses between a REDIRECT and the cover. A redirect cannot sit hidden in a template, so this branch is a routing gate.
The lesson page (Module ยท Content):
- The title, summary, body, next-lesson row and course index are bound to
modulePageViewandmoduleCourseIndex. - A missing neighbour in the next-lesson row is
visibility:hidden, so it keeps its place and "next" stays on the right. - The theory, the examples and the five-way exercise section are ONE nested slot
(
LessonSections). They are lists of embeds whose ADDRESS is data, andLayoutAreaViewopens its stream on the address it already holds, so an embed address cannot be a bound row value. - The course rail still joins when its provider answers. It does not hold the template back.
The tests are LearningJourneyTemplateTests and ModuleTemplateTests. For each template they check:
- there is no deferred view;
- the slots are skeletons;
- every pointer names a key the projection fills, and every key is bound;
- the projection's decisions are right.
The puzzle game (Puzzle ยท Play) had the same problem โ the round being played decides the
page โ and is now a template. Every part of every kind (the explanation card, the round card with
its dots, prompt, how-to, sequence, feedback, Again and Check, and the finish card) is in the
template, and the bound *Styles of PlayView show what the moment calls for. A round's buttons
are two bound lists โ pills for a choice or order round, full-width lines for a spot round โ and a
tap is a row-scoped action (core Doc/GUI/DataBinding โ Row-scoped actions): the row names
the round and the option, item or line the learner tapped, as they saw it. PuzzleAreas.Project
is the pure projection, and PuzzleTemplateTests pins every kind and a live check that the old
deferred game is rejected.
The exercises grid (CourseInvite ยท Exercises / MyExercises) is the worked example. Who is
looking (anonymous, standing in their own copy, or on a central page) is decided on the render
turn from the identity and the path. Everything the install record and the copies decide is data: the
sign-in line, the header with "Open my copy", the cards (an item template over
myExercisesCards) and the install section are all present, and ProjectView picks which one
shows. The install STEP embeds the course root's own InstallCta area, and the course is read off
the host's content, so it is the one nested slot (InstallStepArea): placed while the install
section shows, cleared once the cards replace it. The course-level completion panel is fed the same
live course (CourseCompletionLayoutAreas.PanelFor). ExercisesGridTemplateTests pins every
state, the slot path, and a live check that a deferred grid is rejected.
What this does NOT change yet
The seam and both authored types over it โ Edu/Workbook and Edu/Quiz โ are here, with their
tests. Still ahead:
- Retiring the copy. Half done, and the half that is done is the one that would otherwise be a
defect: an
Edu/Exercisewhose body embeds a workbook or a quiz no longer redirects to the learner's installed copy (KeepsAnswersCentral). It could not: the seam refuses a workbook that is already inside the viewer's own space, so the copy renders the exercise read-only โ the one page the learner was sent to in order to write would be the one page they cannot write on, with nothing erroring. What remains is the other end: a course whose exercises are all data-bound can drop them fromPluginContent.installPathsand stop copying altogether, which is part of the rollout. - Code cells. The pristine cell stays central; the learner's editable cell should be created copy-on-write in their answer space on first edit or run.
- Migration. The plan is written and tested โ see Migrating existing learner answers โ and executes nothing yet. It is additive, never overwrites, never carries authored text, and deletes nothing; it runs as the learner's own act on their own render turn, because nobody can read another learner's space to survey or verify a sweep.
- Rollout. Piloting on
ThinkInStreams, then the rest ofMeshWeaver.EducationandRiskTransferinMeshWeaver.Reinsurance.