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.
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.