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 server was updated. Reloading the page to pick up the latest version.