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

🚨 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 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 the public instance, 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.

Whether the measurement has been taken — how to read the occurrence record

"The next occurrence is the measurement" is only useful with a way to tell whether one has happened, and the incident record is easy to misread in four ways. All four were measured on the control instance, where Admin/_LogIncident lives — not on the portal whose installs fail.

1. The rate of one defect is read from a Shapes entry — never from any node's occurrences. The fingerprint an issue quotes is usually a REPORTER: it carries occurrences and firstSeen for the whole ProvisionPlan install family and spawns successors that point back through reporterFingerprint. 🚨 A successor is not one message shape: it may bucket several (Variants > 1), recording each in Shapes with its own occurrences/firstSeen/lastSeen. So there are two ceilings, and both get mistaken for a rate — the reporter's count is a ceiling over the family, and a successor's is a ceiling over every shape it buckets. Read the matching Shapes entry. Measured 2026-09-21, the two successors carrying the install-verify wording read 89 and 45 against a headline of "4,293 occurrences since 2026-08-24", and only 3 of the newer one's 10 retained samples are this shape at all — so the headline over-states this defect by more than an order of magnitude.

2. A reporter's lastSeen FREEZES once it is superseded, so neither "still firing" nor "it stopped" can be read off it. supersededBy is a moving pointer to the newest spawned shape, not a verdict on this one: the same reporter named two different successors seven hours apart on 2026-09-21, the later one an unrelated Record entries write timeout.

3. "It has not recurred" needs a liveness control IN THE SAME NAMESPACE, or it is indistinguishable from silence — the dead-watcher reading. 🚨 The namespace qualifier is the whole control: LogWatchWorker collects and catches per watched namespace, so incidents can keep arriving from one namespace while the subject's collector is dead. A control drawn from the wrong namespace proves nothing and looks exactly like a good one — the first cut of this section cited six ProvisionPlan folds from the afternoon of 2026-09-21 without noticing that the newest was the control instance's own namespace, while these failures are the public instance's (review finding on Plugins#2228).

So the control is content.namespace:<the failing namespace>, newest lastModified: for the public instance that read 2026-09-21T18:20:46Z, 1 h 38 min before the reading, so that collector was folding. A truncated result set is fine here and only here, because liveness needs a floor on recency rather than a maximum.

The absence half needs the opposite and must be complete: the anchored content.category:ProvisionPlan sweep answered count: 11, truncated: false, coverage.partitions ["admin"], and none of the eleven carried the new wording or a lastSeen after 2026-09-20T13:58:14Z. Floor for the control, complete set for the absence — swapping them is how both halves end up proving nothing.

4. The phase being healthy is a separate, POSITIVE control. The same packages that can never verify for the affected space wrote their manifests for another user — six Store/Install records under that user's _Install container, 2026-09-12 → 2026-09-16. So this is one space, not the phase, and a fix aimed at the phase would be aimed at nothing.

🚨 The subject space itself cannot be resolved from outside, and that is now true by construction. search 'namespace:<space> scope:descendants' answers count: 0 over coverage.partitions ["<space>"] and get answers Not found — the same string a node you hold no grant on gets. A credential that demonstrably reads other users' private homes still cannot separate "torn down" from "denied", and Admin/Partition/<space> is not the tie-breaker: it answers Not found for a space that certainly exists, making it a blind probe rather than a negative control. That wall is exactly why the diagnosis had to move INTO the failing verify instead of being taken from a console.

The measurement came in — and "an ACCESS problem" is several problems (MeshWeaver#5225)

The instrument fired: from 2026-09-22 the affected space's LearningRoadmap and Mail installs fail every boot with "Install stored … but 'x' cannot read them … an ACCESS problem on that space". So the store holds the copies and the owner's own read does not return them. What that sentence could not say is which access predicate hides them, and the candidates have different owners:

Cause What it is Who fixes it
A copy's stored MainNode lies outside the space A viewer-scoped read returns a row when the viewer holds Read at the row's MainNode, not at its path — so no grant on the space reaches the row. A full-instance write cannot move a MainNode back onto its own path (MeshWeaver#2631), which is why every retry leaves it where it is. The writer that stamped it, plus a heal of the stored rows
No owner grant in {space}/_Access The space was never given to its owner; nothing an install writes can change that. Whoever provisions the space (onboarding) — and, for the live row, an owner restoring the grant
A Read cap — a PartitionAccessPolicy with read: false at or above the copy Denies Read to every viewer there, the owner included. Whoever wrote the policy
The owner grant is stored and not in effect None of the above, and a grant naming the viewer with a built-in Read-bearing role is stored — so the permission projection the read consults does not carry it. The access store's projection

It is not the id's shape: with row-level security on (the memex wiring), a hyphenated owner reads what System wrote into their home exactly like a plain one, and a stranger reading the same home sees nothing — the negative control that proves the boundary was live (OwnerReadsOwnSubtreeTests, all three green on the partitioned Postgres backend).

So the failed verify now reads what it needs as System — each hidden copy's stored MainNode (from the diagnosis read it already took), plus the space's AccessAssignments and PartitionAccessPolicys through the SYNCED read, because the decision is made off their CONTENT and a raw query row does not carry content reliably (MeshQueries.QuerySnapshot is metadata-only) — and appends the cause that holds (Localizer.HiddenCauseText). 🚨 It names a cause only on COMPLETE, DECISIVE evidence: a grant listing that came back at its row cap, or an owner grant through a custom role (whose Read bit lives on a Role node the check does not read), is reported as "Cause not named …", never guessed (review findings on Plugins#2358). Each sentence is pinned as a negative control for the others (HiddenCause_NamesThePredicateThatHidesTheCopy), and the mesh-backed Verify_Live_CopiesTheViewerCannotRead_FailAsAnAccessProblem now also asserts the real phase names the Read cap it plants. As before it changes the text, never the verdict, and a cause read that fails or answers nothing says so instead of taking the access diagnosis down with it.

A standing "no owner grant" verdict is not retried (MeshWeaver#5225)

Naming the cause did not stop the loop. StandardPacks.EnsureForAllUsers runs on every Store activation, so one space with no owner grant re-copied the same packs and logged the same Error on every pod roll — 791 lines for ONE user in two weeks, every one a verdict that could not change until somebody restored the grant.

The sweep now asks, before it re-runs a pack that has no manifest, whether the earlier run's verdict still stands (StandardPacks.AccessVerdictStands). The evidence is already on the mesh, so nothing new is recorded: the copy phases land BEFORE the verify and the manifest is written only AFTER it, so an install target System can read with no manifest means an earlier run stopped at or after the copies. Whether its verdict still stands is the verify's own predicate, asked again: does the owner's viewer-scoped read (Localizer.ViewerSubtree, the read the verify takes) return that target? Only when it does not is the pack skipped, emitting nothing (InstallUnlessAccessVerdictStands) — no copy, no second report. The run that reached the verdict is the one that reported it.

🚨 It asks the EFFECTIVE read, never _Access itself. A grant can reach the owner through a group assignment or a publicRead policy with no assignment naming them directly, and a check of _Access for one would have held back installs the owner could read (review on Plugins#3184).

It is a condition, not a timer: both reads are fresh one-shot listings, so the first pass after the owner can read the target installs the pack. A first install (nothing stored) is never blocked, and a guard read that faults or answers nothing decides nothing — the install then runs as before and its own verify reports what is true. Pinned by the StandardPacksBatchTests "ACCESS verdict" cases. Not covered: two pods whose sweeps reach the same user before either has stored a copy can each report the verdict once.

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.