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 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 lesson page's copy-on-first-open is no longer such a copy (Plugins#1606).
Edu/Lesson'sEnsureCopyused to copy the course's declared roots itself, with core'sNodeCopyHelper, and wrote no manifest — so a course a learner first met through a lesson had no item, no fingerprints, and nothing above could ever reach it: measured onsglauser/AgenticBusiness, 60 nodes frozen at version 1 since 2026-08-31 while the central course had been fixed, showing the learner an authoring warning the course no longer had. It now files aStore/InstallRequestnode at{viewer}/_InstallRequests/{package}and the type's watcher runs THIS engine — therequiresclosure leaves-first, then the package into{viewer}/{package}with the entitlement gateEntitlements.RequiresEntitlementcomputes — so a first-open copy and a Get from the cover are the same fact: the same item, the same fingerprints, the same Apps-band tile. The request is the front door for any caller that cannot compile againstStore/Installer/Source(cross-type reuse is source inclusion, and the writer is 466 KB); it carries onlypackageId,requestedAction: Installand avialine, the viewer is the partition the node sits in, and the invoker (the framework-stamped author) must be that owner or the System identity — nobody installs into somebody else's space through it. The outcome streams back onto the node (state/message/error/installedPath/log); a learner reading a public lesson of a course they never acquired gets aFailedrequest naming the refusal and the read-only master. 🚨 The viewer's entitlement to the package is verified BEFORE the dependencies run (InstallRequestControlPlane.InstallChain):DependencyInstall.InstallDependenciesgrants the viewer each auto-installable dependency, because its ordinary callers — the cover's Get, an acquisition's tail — have already established the viewer's claim on the dependent, and a request has established nothing; run first, it would hand a requester of a gated course every dependency before the engine refused the course. The engine's own gate stays where it is; this one keeps the dependencies behind it (TheEntitlementGate_RunsBeforeAnyDependencyIsGranted). 🚨 And a malformed request is reported, never dropped: the watcher gates onIsArmed(action asked, no run in flight), not onShouldRun, so a request that names no package reachesRefusaland lands asFailedwith the reason — gating onShouldRunleft exactly that request parked atRequestedforever (ABlankPackage_IsArmed_SoItIsReported_ButNeverRuns). Edu declaresStore@^1.14.0for this type; an older Store satisfies nothing here. Pinned live byInstallRequestTests.ARequest_Live_InstallsThePackage_AndWritesTheStoresOwnRecord, which removes both partitions it mints whatever the verdict. The module-level copier behindStartExercise/GoToMyCopy(CourseShellAreas.EnsurePersonalCopy,IMeshService.CopyNode) is NOT routed through it yet and still records nothing. - 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.
The copy and the verify share ONE notion of "the same node" (MeshWeaver#4817)
[ProvisionPlan] 'Voice' FAILED in phase 6/8 'Verify install': Install did not persist 1 node(s): 'rbuergi/Agent/voice' … please retry the install — 42 occurrences over six pods in the two days
this fingerprint has existed (and under its predecessor's since 2026-09-12), the same install
re-failing at every pod boot. It was never a lost write and never a stale read, and no retry could
have passed.
Measured on memex.meshweaver.cloud, 2026-09-20. The package ships its agent at
Voice/Agent/voice. The viewer has held rbuergi/Agent/Voice — capital V, created 2026-08-18 —
ever since. get @rbuergi/Agent/voice answers Not found; the copy phase's read of the declared
root's installed subtree, path:rbuergi/Agent scope:subtree (the source has a node at
Voice/Agent, so that is order[0]), lists rbuergi/Agent/Voice. Then, inside ONE install:
| phase | question | comparer | answer |
|---|---|---|---|
copy (RefreshPlan.Of, CopyPlan) |
does the viewer already hold this copy? | OrdinalIgnoreCase |
held — nothing is written |
verify (MissingInstalledPaths) |
is the expected copy present? | Ordinal |
missing — the phase fails |
Each half is defensible where it stands. The copy is right: a path is a case-insensitive identity
to the platform — the in-memory store keys nodes OrdinalIgnoreCase, so a create at the other
casing would land ON the viewer's node and overwrite it; Postgres folds namespace/nodeType
equality and indexes LOWER(path), so a second row would sit behind every listing of that
namespace. Together they are an install whose verify can never succeed — the copy will never
write the node and the verify will never see it — and StandardPacks.EnsureForAllUsers re-enters
it for every user at every activation, because no manifest is ever written.
The fix is one statement of path identity — Localizer.PathIdentity — read by both halves
(CopyPlan, RefreshPlan.Of, MissingInstalledPaths, MissingAfterInstall). It loosens nothing
about presence: a copy absent under every spelling still fails by name, and
Verify_ACopyHeldUnderAnotherCasing_IsPresent_ButAnAbsentOneStillFails pins both directions. The
viewer's own differently-cased node is kept, exactly as any other unrecorded copy is — the
install is never destructive.
The second shape on that issue, and why the failed verify now asks the store
The other lines on #4817 are a different shape: for one user, every expected path missing —
the home root included — for every package, at every boot. 🚨 That one was not measured: the
user's space is outside what the investigating credential can read, and Not found from outside
your own denominator closes nothing.
What the code establishes is that the two halves of an install do not run as one identity: the
all-users sweep writes as System into a home that is not the caller's, while the verify reads
with the viewer stamped by value (ViewerSubtree, see above). A home whose owner cannot read
it therefore takes every write and shows its owner nothing, and the verify's text — "accepted but
not stored (a transient hub error?) … please retry" — offers two guesses, neither of which is
that.
So a verify that has already failed looks once more at the same scopes as System
(SystemIdentity.Run, sealed) and says which it was (Localizer.NotPersistedOrHiddenText):
"Install stored …, but 'x' cannot read them — … an ACCESS problem on that space, not a lost write.
Retrying will not help." It changes what the failure says, never whether it fails: a viewer
who cannot read their install has not been given one, no manifest is written, and a probe that
faults or answers nothing leaves the original failure in place.
🚨 The System look is taken after the viewer's last look, so a copy that landed in between would
be in the store and not in the viewer's answer — for timing, not access. So the viewer is asked
once more, after the System read: a copy they can see now was late and keeps the retry text;
only one the store holds that the viewer still cannot see is named an access problem. The
mesh-backed case Verify_Live_CopiesTheViewerCannotRead_FailAsAnAccessProblem drives the real
phase over content written as System into a home the probe viewer cannot read. The next occurrence
is the measurement.
🚨 "Holds no grant on" is not enough to make a home unreadable on the gate. The first cut of
that case leaned on the framework being deny-by-default and was red on mw-plugin-test: the gate
seeds Public as ROOT Admin (PluginGateRunner.RootAdminAccess, so every install a gate runs has
an admin to authorize it), and a probe viewer with no grant of its own therefore reads everything
there. The viewer-stamped settle loop saw both copies, the phase failed on the never-written path
alone with the lost-write text, and the red read as "the wrong message" when it was a premise that
had quietly not held. The case now CAPS the home with a PartitionAccessPolicy { Read = false } —
the one instrument that binds every viewer, admins included, while System bypasses row-level
security — and ASSERTS the premise before the verify runs (the System read returns both copies, the
viewer's own read returns nothing), so a harness where either is false fails by name rather than
downstream.
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.