A declined package names the plan, not the grant

An operator message is an instrument. This one answered confidently about a knob it had never read, and two separate passes followed it to a remedy that does nothing.

The sentence, and what it cost

RegistryPackages.NotOffered is what the Store's provisioning banner and the Admin/Maintenance RefreshModules task print when a registry declines a package. Until #1802 it read:

the registry R does not offer a package 'P' to this instance — check the instance's catalog grant

On #1584 — the one case anybody actually chased — the grant was already correct, and both readers did what the sentence said:

when the prescribed remedy what it would have done
2026-09-10 03:07Z "grant the memex instance the personal plan on the registry (Plugins/*@personal)" nothing
2026-09-13 08:17Z "grant the control instance's registry key the plan its installs need (Plugins/*@enterprise covers all eleven)" nothing

Measured on the registry (memex.meshweaver.cloud) 2026-09-13 17:26Z: Admin/_PluginGrant/memex was at v4 with entries Plugins/* and Crm/*, neither carrying a tier — the grant already reached every Plugins package — while rbuergi/MeshWeaverInstance/memex was at v1, unchanged since 2026-08-06, with no plan field at all. PlanTierRanks.CoversInstance reads a blank plan as BaselinePlan = "free", deliberately. The refusal was the instance's plan.

And a grant edit cannot fix a plan refusal even in principle. Since MeshWeaver#2804 an entry's tier is a CAP that only lowers:

// PlanTierRanks.Narrower
return capRank < planRank ? capId : plan;

Narrower("free", "enterprise") is "free". Applying either remedy above would have produced no change at all, and would have read as "the fix didn't work" — which is the second cost, after the wasted pass: a correct diagnosis arriving later has to overcome an experiment that appeared to falsify it.

This is the same family as a message that reported an ABSENCE in the vocabulary of a DISAGREEMENT and sent a session chasing a bake epidemic that did not exist. The defect is never the wording on its own; it is a sentence that states a cause it did not measure.

Two causes, and a registry answer that could not tell them apart

Before MeshWeaver#4097 a plan-tier refusal was indistinguishable from "no such package" on the wire. That is not a bug — it is the enumeration defence: a package outside an instance's granted sources must stay byte-identical to absence, or a registered instance could map out everything a registry carries by asking for names.

#4097 opened exactly the crack that is safe to open. RegistryPackageSource.ListCatalog returns a RegistryListing carrying both halves:

public sealed record RegistryListing
{
    public IReadOnlyList<PackageManifest> Packages { get; init; } = [];
    public IReadOnlyList<PlanTierRefusal> Refused { get; init; } = [];
}

PluginGrant.TierRefusal answers only when some grant entry already reaches the package and the plan is what refuses it — i.e. only for a package the registry itself declared in this instance's default set. Nothing new is disclosed.

So there are now two shapes, and they want two different sentences:

the registry's answer what may be said
a typed PlanTierRefusal naming this package the cause: the plan, the tier required, and where the plan is written
no refusal for this package (pre-#4097 registry, or a refusal outside the default set) the candidates: grant and plan — and no prescription

The second row is why the old wording had to change too, not merely gain a branch. A refusal for a package outside the default set is withheld by the same rule that withholds an ungranted one, so "check the grant" is a guess in that case as well; it now names both knobs and says the registry reported no reason, so a reader knows they are holding a candidate list rather than a diagnosis.

Why the corrected sentence cannot drift

The fix is structural, not a re-wording. RegistryPackages.Install now reads ListCatalog instead of ListPackages — the whole answer rather than its first half — and the refusal branch carries core's own sentence verbatim:

public static string NotOffered(
    string packageId, string registryUrl, IReadOnlyList<PlanTierRefusal>? refused) =>
    (refused ?? [])
        .FirstOrDefault(r => string.Equals(r.PackageId, packageId, StringComparison.OrdinalIgnoreCase))
    is { } refusal
        ? $"{refusal.Describe()} Raise the PLAN on the registry {Normalize(registryUrl)} — …"
        : NotOffered(packageId, registryUrl);

PlanTierRefusal.Describe() is the one method every consumer surface says a plan refusal with — /health required_modules, the package card, the self-updater. There is no second copy of those words in this repository to go stale, and the verdict comes from the registry's own PluginGrant.TierRefusal rather than from anything this repo re-decides. A message written beside the code that decides drifts from it; a message derived from the decision cannot.

What this repo adds is only the remedy — Settings → Instance Grants → the plan form, on the registry — because the registry knows the refusal and not the operator's route to it. That is pinned by its own assertion (NotOffered_WithAPlanRefusal_NamesThePlan_NotTheGrant), together with a negative that fails if the guessing sentence ever comes back into the branch that has a verdict.

What inherits the fix without changing

Both consumers read the thrown exception's Message:

The limit, stated

A registry that predates #4097 sends no refused member at all, and a plan refusal for a package outside the instance's default set is never reported by design. Both land on the honest fallback. The sentence is therefore not a guarantee that a plan problem will be named — only that it will never again be mis-named as a grant problem.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.