Installing — add what is missing, refresh what nobody changed, never touch what was edited
A package's installPaths are copied into the viewer's own space ({viewer}/{package}, plus
{viewer}/Skill and {viewer}/Agent for the fixed segments) by Localizer.Localize — the one
engine behind Get, Repair, Update, every acquisition's self-install, and the whole-course
row of the per-exercise Restore dialog. This page is the contract that engine keeps for copies that
are ALREADY there.
The problem this replaced
Until Store 1.9 a second run only ever added: every path the viewer held was excluded up front
(Localizer.CopyPlan) and the bulk create verb skipped existing paths server-side. That kept the
learner's work safe by construction, and it also meant a copy was a snapshot forever: a fix the
course authors shipped inside a copied page reached only learners who had not installed yet. The
cover already knew the source had moved (InstallStatus.Evaluate → Outdated, the hero relabelled
Repair as Update), but Update ran the same add-only plan and changed nothing.
Measured on AgenticOffice (MeshWeaver.Education): the exercise pages' back-links were rewritten in
the repo on 2026-08-05 at 15:31 UTC; a learner's copy taken that morning at 13:44 UTC still carried
the dead ../../ links a month later — all 24 files that commit touched, plus 14 more that changed
afterwards — and nothing short of Reset (uninstall + Get, which discards every edit) could mend
it. The per-exercise Restore dialog could not tell that copy apart from an edited one either: it
fingerprints the copy against the current source, so "stale because the source moved" and "the
learner's work" read the same, and both sat behind the discard-your-edits confirmation.
The record: what the install wrote
Every run records, per copy it writes, the content fingerprint of what it put there —
ContentFingerprint.Of(written), the same primitive the Restore dialog compares with — in the
viewer's install record, {viewer}/_Install/{repo-slug} → the item's fingerprints, keyed by the
copy's path relative to the viewer (AgenticOffice/01-Willkommen/Exercise/WasWeissDerAgent,
Agent/Coach), so the fixed-segment roots that land beside the target are covered too.
A fingerprint ignores the render cache (prerenderedHtml) and the sync stamp, so re-rendering a
page is not an edit (RepairTests.RenderingACopy_IsNotAnEdit), and it is taken AFTER the install's
own transform (re-anchored @/@@ references), so an untouched copy fingerprints exactly as its
source would (RepairTests.SourceFingerprint_MatchesThePristineCopy_DespiteTheInstallTransform).
The rule (RefreshPlan.Of, pure)
For every node the install would write today, against the copy the viewer holds and the recorded fingerprint for that key:
| the copy | the record | verdict |
|---|---|---|
| absent | — | Add (bulk create verb; a create can never overwrite), and record it |
| equals the record | source moved | Refresh with the current source (upsert verb), and re-record |
| equals the record | source did not move | nothing to write; the record stands |
| differs from the record | — | Keep — the learner's work; the record is NOT touched, so reverting to the pristine text makes the copy pristine again |
| no record, equals today's source | — | Keep, and record it now — pristine from here on, so the next update can refresh it |
| no record, differs | — | Keep — stale or edited, the engine cannot tell, and guessing wrong costs someone their answers |
The run reports what it did beyond adding: the terminal LocalizeProgress carries Refreshed and
Kept, the dialog's status string trails them (done:{n}:{target}:{refreshed}:{kept}), and the
finished line says "{0} page(s) you had not changed were brought up to date; {1} that differ from
the course were left as they are." The second count is deliberately not called "your edits": a copy
with no record differs for reasons the engine cannot see.
What it does not do, on purpose
- A copy made before the record existed (or by the lesson page's own copier,
Edu/Lesson'sEnsureCopy, which writes no manifest) is never refreshed while it is stale. It enters the record only through the "no record, equals today's source" row. The way out for a learner is still Reset, or the per-exercise Restore behind its confirmation. - The per-exercise Restore dialog is unchanged: its rows are
*/Exercisesubtrees, it still compares against the current source, and it is still the way to get the pristine version back OVER an edit. - Uninstall clears the record with the coverage list: a fresh copy must never pass for a pristine old one.
🚨 Reset removes nothing unless the read that plans it names the viewer BY VALUE
Reset to defaults is Uninstall(...) concatenated with a fresh Localize(...)
(InstallDialog.ResetToDefaults), and Localize is additive by contract — the bulk verb skips
existing paths server-side. So the whole of Reset's destructive half is the uninstall's delete
set, and if that set is empty the reset is a no-op that still reports success.
On memex, 2026-09-07 08:11:48–08:13:32Z, that is exactly what happened. The dialog read
✅ Done — 11 item(s) installed in rbuergi/AgenticEngineering; afterwards all 211 copied nodes
were still at version 1, created 2026-08-13, carrying the pre-2026-09-04 quiz. A learner who
resets to discard their edits, or to pick up a corrected exercise, kept precisely what they wanted
gone.
The mechanism, and why it only bites in production
Uninstall derived its delete set from an unstamped request:
MeshQueries.QuerySnapshot(mesh.Query<MeshNode>(MeshQueryRequest.FromQuery(
$"path:{nodesTarget} scope:subtree limit:{SnapshotQueryLimit}"))) // ← no viewer
FromQuery(query) leaves UserId null. MeshService.Query stamps the ambient viewer at call
time — not at subscribe time — and deliberately does not pin the Anonymous fallback. This request
is constructed inside a SelectMany over the manifest read, i.e. on the thread that delivered that
result: a hub action-block or IIoPool thread the circuit's AsyncLocal AccessContext never
reached. So the viewer resolved Anonymous, RLS dropped every node in the learner's private
partition, and the read answered empty — which is byte-identical to "nothing is installed
here". The record was cleared, the additive re-install skipped all 211 existing paths, and the
dialog reported Done.
It is production-specific for a reason worth remembering: on a single-pod disposable mesh the same code replaces the copy correctly (MeshWeaver.Education #281, run 34125224148, twice), because there the ambient context happens to survive. A green e2e on a fresh mesh is not evidence about this defect.
The rule
Every read whose result composes a WRITE — and every read whose emptiness would be shown to a human as "you have nothing here" — passes the viewer as a VALUE, and fails closed:
Localizer.ViewerSubtree(path, viewer) // FromQuery(...).ForViewer(viewer).RequireViewer()
ForViewer is what makes it independent of any scheduler hop; RequireViewer is the half that
matters most, because stamping alone still answers empty when the viewer is blank, and empty is
again read as "already clean". With it, a viewer that cannot be resolved throws
QueryIdentityUnresolvedException — "I could not see it" can no longer be read as "it is not
there". Three reads on this path were unstamped and all three now go through it: the uninstall's
delete set, CopyRoot's installed-scope read, and the install verify's re-read.
🚨 Not AsSystem(). These results compose the DELETES, and a system-stamped emission carries the
elevation into every write built on it — the defect
UninstallsSystemRead_DoesNotStampTheDeletesOrTheResetThatFollows exists to pin. Reads that
genuinely need elevation (the gated fixed-directive sources) are wrapped in SystemIdentity.Run,
which scopes the impersonation around the subscribe instead.
Two things this does not fix, deliberately
- The Done line still counts declared roots, not effects.
✅ Done — 11 item(s)isroots.Countfrom the localize plan; it never said anything about the uninstall, and it would have read the same over zero removals even with the delete set correct. Reporting the effect — nodes removed, andCreated/ExistingfromCreateNodesResponse, which are already returned and already discarded — is the remaining half. - A verification built on the same read cannot fail. An uninstall verify that re-reads with an
unstamped request and intersects planned with still present yields zero survivors from an
empty answer and passes. The verify must use
ViewerSubtreetoo, and its log must sit above thetotal == 0early return — otherwise it is unreachable in exactly the case it was written for.
Where it lives
Store/Installer/Source/RefreshPlan.cs (the decision), Localizer.CopyRoot (adds via
CreateNodes, refreshes via sequential CreateOrUpdateNode, the same write ItemRestore.RestoreItem
makes), Localizer.WriteManifest → InstallManifest.WithFingerprints, InstallDialog.DoneCounts,
StoreTexts.Refreshed. Pinned by RefreshPlanTests, the provenance cases in
InstallManifestTests, LocalizerTests.InstallProgress_TheTerminalEvent_CarriesTheRefreshTally and
InstallDialogTests.Progress_CarriesTheRefreshCounts_AndTheDoneLineSaysSo.