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

🚨 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

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.WriteManifestInstallManifest.WithFingerprints, InstallDialog.DoneCounts, StoreTexts.Refreshed. Pinned by RefreshPlanTests, the provenance cases in InstallManifestTests, LocalizerTests.InstallProgress_TheTerminalEvent_CarriesTheRefreshTally and InstallDialogTests.Progress_CarriesTheRefreshCounts_AndTheDoneLineSaysSo.

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