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:

๐Ÿšจ 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.

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:

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:

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:

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:

  1. Every variant is in the template, and a bound style shows one. Use this when the set of variants is known in advance.
  2. A bound list of uniform rows. Use this when the count is data but every row has the same shape.
  3. 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 lesson page (Module ยท Content):

The tests are LearningJourneyTemplateTests and ModuleTemplateTests. For each template they check:

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: