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:

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:

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