Plugin Registry
For the step-by-step how-to (author → publish → install → own registry) see the Plugin Manual.
Plugins are folders of mesh nodes in a git repo, each rooted in a
node-native <Plugin>/index.json Store/Plugin node carrying a PluginContent
(anatomy) — a package.json manifest is the alternate,
non-default source format. Installing one means importing its nodes and compiling its node types
live. But you do not want every installation to hold GitHub credentials for a private plugins
repo just to receive a plugin.
The registry solves that. One MeshWeaver instance (the plugin registry instance) is the registry: it alone holds the source credential, reads the plugins repo, and re-serves the catalog over an authenticated HTTP surface. Every other installation browses and installs from the registry, never from git. The credential is encapsulated in the registry — exactly like npm or NuGet, where the registry has source access and clients just speak HTTP.
Systemorph/MeshWeaver.Plugins (git, private)
│ the registry reads it with its ONE GitHub App credential
▼
┌───────────────┐ GET /api/plugins ┌────────────────────────┐
│ registry │◀───────────────────────────────│ installation (consumer)│
│ memex.mesh… │ (Bearer mwi_ instance key) │ the Store's catalog │
│ holds the cred │ POST /api/plugins/files {id} │ + boot-time default │
│ │────────────────────────────────▶│ install │
└───────────────┘ {packages} / {files} │ no GitHub credential │
└────────────────────────┘
The surface — registered instances only, curated
Two endpoints, mapped by PluginRegistryEndpoints (memex/Memex.Portal.Shared/Api). The surface is
not public: it serves only registered MeshWeaver instances. A caller presents its instance
key as Authorization: Bearer mwi_… (its PluginCatalog:RegistryToken, or per-registry
Registries:N:Token); InstanceRegistryAuthenticator resolves that key to a MeshWeaverInstance
node and the admin-owned PluginGrant that says which (source, package) pairs it may read, and a
request without a valid key is 401.
The gate fails closed: PluginCatalog:RequireInstanceKey defaults to true, so a registry that
configures nothing refuses anonymous callers rather than serving them everything. The anonymous mode
must be asked for explicitly (RequireInstanceKey=false — local dev / the e2e stub) and warns on
every request.
🚨 The flat
PluginCatalog:RegistryTokensallowlist this replaced is obsolete and no longer read (PluginRegistryTokens.SectionName/.Validateare[Obsolete]). It was open when unset, which is how this registry served its private sources to anonymous callers until 2026-08-06, and a flat list could never express WHICH plugins an instance may pull. Adding a token to it today gates nothing.
Registration itself is self-service (Settings ▸ Instances issues an mwi_ instance key), but it is
identity, not entitlement: what an instance may pull is decided per (source, package) by a
platform admin in Admin/_PluginGrant/{instanceId} nodes the instance's owner cannot write
(Admin app /Admin ▸ Instance grants). The one qualification: the registry operator may opt
specific sources into every new registration via PluginCatalog:DefaultGrants (a list of
Source/Package entries, e.g. ["Plugins/*"] so a fresh install gets the platform plugin repo
with no admin step). Registration then seeds those entries into the grant node — the node stays
the single authority, so an admin can still revoke or extend per instance — and private/paid
sources are never listed there. With no defaults configured, registering grants exactly nothing.
The seed is declared on the registry's deployment record — defaultGrants on
Hosting/Deployment (DeploymentContent.DefaultGrants, e.g. "defaultGrants": ["Plugins/*"]) —
and every renderer delivers it from there: the Helm values' pluginCatalog.defaultGrants, the
operator's boot catalog file and the Aspire portal keys, all as PluginCatalog:DefaultGrants:N.
Before the field existed the seed lived only in a hand-edited Helm overlay, so a re-render from the
record dropped it and the deploy-drift sentinel reported it as never delivered (Memex#116).
The list renders only for a record with isPluginRegistry: true
(DeploymentPortalConfig.RegistrySeedPatterns): a consumer registers AT a registry and never
seeds grants, so on any other record the list renders on no route. Duplicates are dropped by what
an entry grants, the way the grant matches: the source and the plan compare case-insensitively and
the package id exactly, so Plugins/Foo and Plugins/foo are two grants and both stay.
First-startup auto-registration removes the remaining hand-off. A platform admin mints a
registration key (mwr_… — Admin app /Admin ▸ Instance grants ▸ Registration keys;
reusable, revocable, optionally expiring) and puts it in the deployment scaffold. A new install
configured with PluginCatalog:BootstrapKey + PluginCatalog:InstanceId then registers itself
on first boot via POST /api/instances/register: the bootstrap key resolves to its minting admin,
the instance is created under that identity (exactly as if that admin had registered it by hand,
DefaultGrants seed included), and the response carries the instance's own mwi_ key once —
the install persists it (Admin/PluginRegistryCredential/…, enc:-protected at rest when a master
key is configured) and presents it on every catalog call from then on. Nobody copies a token.
Revoking the bootstrap key stops further registrations without touching the instances it already
created; a bootstrap key is never accepted on the catalog surface, and an instance key is never
accepted for registration (mwr_ vs mwi_ — disjoint by shape).
And it installs. A grant is entitlement, not content — so a registered instance would still show an empty (if authorized) catalog until an admin clicked Install on each package. Two keys close that, and they are independent:
PluginCatalog:InstallPreInstalledPackagesreconciles the packages whose manifest declaresPreInstalled— the platform's own baseline (Agents, Skills, Essentials, …) — on every boot, because that baseline is what the platform needs to function and what must survive a self-update. On an up-to-date instance the content-identity gate turns it into one catalog listing and no writes. It is also the only mechanism that can heal an instance whose baseline partition was lost. 🚨 Unset, it followsInstallByDefault: it applies unlessInstallByDefaultdeclares an explicit set — every entrySource/Package, none a whole sourceSource/*. A deployment that names its packages gets exactly those and what they declare inrequires; set the keytrueto keep the baseline beside the set,falseto suppress it everywhere (an explicit value always wins) — policydeclared-package-set-is-exact. Before that policy the baseline was added on top of any set: a fresh control instance naming five packages selected 48 (measured 2026-10-13) — everypreInstalledpackage of the registry — and on its closed type set each one's in-mesh NodeTypes parked. See The bootstrap: modules first, then nodes.PluginCatalog:InstallByDefault(default empty) seeds each matching package once. The pass runs on every boot and is gated per package by the default-install ledger (Plugins/_DefaultInstallLedger): a catalog entry matching theSource/Packagepatterns that the seed never delivered is installed, through the same path the Install button uses; one it delivered before is left alone forever, even if it is gone now — only an operator removes a package, and the seed does not fight an operator. That is also what repairs a bad first config or a failed install on the next boot. A package newly listed in a source this instance already seeded from, and covered only by a whole-source pattern such asPlugins/*, is held: it lands through a governedpackage.provisionactivity, or by being named exactly — and a held package gets no ledger entry. A commercial package (a price or a sales contact) named exactly (Source/Package) is installed, together with what it requires (commercial requirements included), on the authority of the deployment configuration — the principaldeployment-config:{InstanceId}, see the deployment configuration authorizes what it names. A commercial package covered only by a whole-source pattern names nothing: it is never installed (nor pulled in as a requirement) by this pass and is recorded underskippedon the ledger with its reason. Our deployments set["Plugins/*"], so a new portal comes up with the platform plugins — the Store included — already present and (perAutoUpdateByDefault) tracking their repo. 🚨 Over a local checkout — aPluginCatalog:Sources:N:RepoPaththat is a directory on the host, which is what a self-registrymemex-localmounts — the same patterns do NOT seed once: they are reconciled on every boot, because a mounted working tree is standing operator intent and nothing else refreshes it (the update watcher needs webhooks a local install never receives; the registry reconciler needs a registry). Seeded once, a local portal silently served a week-old course (#3359). It costs nothing when nothing changed, and a disabled feature flag still excludes.Features:Flags:{name}:Packagesasserts a per-environment policy, reconciled on every boot: what THIS deployment always has, with a declared-but-disabled flag EXCLUDING what it names. 🚨 Use this, notInstallByDefault, to ASSERT what a portal always has — the seed is ledger-gated, so it never re-installs a package it delivered once and an operator removed. Full treatment: Environment Composition.
The bootstrap: modules first, then nodes
A mixed package's MODULE lands before ANY package's nodes are written (policy
default-install-modules-first) — phase 1 of the default install (InstanceAutoRegistrationService.LandModulesFirst), for every selected package that has no
install record yet. Phase 2, the node install in dependency order, starts only once every module of
the selection has been decided.
The order is the fix for a cycle the store has with itself on an image that ships none of the modules it delivers (MeshWeaver.Plugins#2970 took them out of the images):
| package | its nodes need | which only exists once |
|---|---|---|
Store |
Skill (a Skill node) |
the MeshWeaver.AI module is loaded — AI delivers it |
AI |
Store/Plugin (its root) |
Store's nodes are installed |
| every other package | Store/Plugin (its root) |
Store's nodes are installed |
The installer refuses a node whose NodeType is not registered (PackageInstaller.ValidateBulkTypes,
"NodeType(s) not registered"), and a module used to land only after its own package's content had
validated. So nothing could install first and no module ever reached the volume — measured on a fresh
control instance, 2026-10-13: Install of 'Store' failed: NodeType(s) not registered: Skill,
Install of 'AI' failed: NodeType 'Store/Plugin' is not registered, 0 installed, 0 up to date, 48 failed, /data/modules empty.
A module's bytes depend on no node, so phase 1 lands them all (fetched from the registry, link-probed,
written to modules/). A brand-new module is NotHeld in the running process and activates at the
next restart after a proposed module set names it (Live Module Update);
the pass proposes that set ONCE, at its end (checked, see below), and the landing raises the
pending-restart marker the self-updater takes. The pass after that restart finds Skill/Agent
registered and installs Store first — the declared requires already put it ahead of every package
rooted on Store/Plugin — and the rest after it. The validation itself is unchanged: phase 2's
failures in the first pass are recorded as FAILED with their cause and retried by the next pass.
Phase 1 applies the gates the install funnel applies before a file travels — the entitlement (with
the same deployment-config principal), licence acceptance and the declared parameters — and the
partition-ownership hold (a candidate whose partition another writer keeps current is held by the node
install and lands no module ahead either) — so a package the funnel would
refuse lands no module ahead of that refusal; the platform floor is the adoption's own hold. An
already-installed package keeps its module on the funnel (after its content, inside the floor and
update-policy holds), so phase 1 never lands drift those holds gate. The pass proposes the module set ONCE, at its end —
phase 2's per-package installs propose nothing (InstallOrUpdate(…, proposeModuleSet: false)) — checked
against the install records AND the whole selection (ModuleDependencyFloor.ProposeChecked with the
selection's manifests), so a fresh package's declared floor binds the set before its record exists; an
enabled entry for a module of a carrier the phase could not clear blocks the proposal. The pass's summary line names
what phase 1 landed: MODULES LANDED ahead of content: […]; a landed module loads at a restart only
once a proposed module set names it, and the phase logs whether its proposal was written or refused.
Pinned by StoreBootstrapLandsModulesFirstTest (a fresh mesh: the store's packages fail validation
exactly as production did, every module of the selection lands, the selection is the declared set and
its requires, never an unnamed preInstalled package) and StoreBootstrapCompletesAfterTheRestartTest
(with Skill registered, one pass installs Store, then AI, then what requires it), both against
an in-process registry serving the real wire shapes (test/Memex.Portal.Shared.Test).
🚨 The selection is source-scoped, and that is a security property, not a convenience. An instance is routinely granted the platform repo and paid course content; "install everything I'm entitled to" would auto-install the paid content. Matching is against the catalog entry's
Source— stamped by the registry as it merges its sources — soPlugins/*can never reach anEducationpackage. A registry too old to stampSourcematches nothing and installs nothing, failing closed. The default install seeds each package at most once (the ledger), so it never re-asserts itself against an admin who uninstalled something.
What a registered instance can then read is curated plugins only:
by default the node-native repos the MeshWeaver.Plugins repo ships —
<Plugin>/index.json Store/Plugin roots carrying a PluginContent, node-per-file — via a
NodeRepoPackageSource (PluginCatalog:SourceFormat=node-repo, the default). A package.json-manifest
repo can be served instead with SourceFormat=package-json. Nothing outside a published plugin is
exposed, and the registry's own credential never leaves.
| Verb | Body | Returns |
|---|---|---|
GET /api/plugins |
— (?ref= advisory) |
{ packages: [PackageManifest…] } |
POST /api/plugins/files |
{ id } (ref optional/advisory) |
{ files: [{ relativePath, content }…] } |
A package is addressed by its id only; the registry resolves what that plugin ships from its configured source — the consumer never supplies a folder path.
Both are backed by the registry's configured git IPackageSources. A
registry can serve several sources — e.g. the plugins repo and an education-content repo —
via the PluginCatalog:Sources list:
{
"PluginCatalog": {
"Sources": [
{ "Name": "Plugins", "RepoPath": "https://github.com/Systemorph/MeshWeaver.Plugins", "Ref": "main" },
{ "Name": "Education", "RepoPath": "https://github.com/Systemorph/Education", "Ref": "main" }
]
}
}
Each entry takes RepoPath (a URL → via GitSync's client, or a local path), optional Subdir,
Ref (default HEAD) and Format (node-repo default / package-json). A source lists only
folders matching its format — the Education repo's courses (DataModeling, AgenticEngineering, …)
appear once each course folder gains a <Course>/index.json Store/Plugin root carrying a
PluginContent, the same node-repo convention MeshWeaver.Plugins uses. GET /api/plugins
merges all sources' packages (on an id collision the first configured source wins); /files
resolves an id in the same order. With several sources one failing repo degrades to an empty
contribution (logged), never a broken catalog. The legacy single-source keys
(PluginCatalog:SourceRepoPath/SourceSubdir/SourceRef/SourceFormat) keep working when no
Sources list is set. A source in another organisation, or one the instance's App does not
reach, states its own credential by reference (TokenKey / InstallationOwner) —
Plugin Source Credentials. The registry is authoritative on each source's git ref; a consumer's ref
is advisory. The wire shapes are produced by PluginRegistryPayloads and parsed by
RegistryPackageSource, one place each, so producer and consumer cannot drift.
🚨 A PluginCatalog node carries the same Format knob, with the same default — a browse page
over a repository is the same question as a configured source over one, and it must answer it the
same way. It did not until #3384: the node's content had no format field, so the browse view always
built a package.json source while PluginUpdateWatcher read the identical record as a node repo.
A catalog node over a local node-repo checkout therefore rendered "No installable packages found."
while its own watcher listed those packages happily. One record with two readers must have one rule;
that rule is PackageSources.IsNodeRepoFormat for a DECLARED format, wrapped by
PackageSources.IsNodeRepoFormatOrDetected for the undeclared case below — and every reader calls the latter.
🚨 An UNDECLARED format on a local checkout is DETECTED from the layout, never defaulted. #3384's
unification turned every undeclared package.json catalog into an empty page: the browse view had
read package.json before, so no such catalog had ever needed a declaration (measured on the Plugins
pin move to 3.0.0-ci.7917 — two migrated PluginCatalog tests rendered the catalog shell with zero
cards over catalog/<id>/package.json). The rule, in PackageSources.IsNodeRepoFormatOrDetected(format, repoPath, subdir): a declared Format wins; with none, <subdir>/<id>/package.json present and NO
<x>/index.json Space root anywhere (repo root or subdir) reads as package-json; every other
layout — node-repo roots, both shapes at once, an absent or unreadable directory, a URL (nothing to
inspect before the fetch) — is the shipped node-repo. Unambiguous evidence for the manifest shape
is the only thing that overrides the default.
The scan looks exactly ONE level down (<child>/index.json at the repo root and under the subdir — Space
roots, not any index.json anywhere) and never deeper, and it is deliberately not more general than
that: a repository that carries node-repo Space roots BESIDE a package.json catalog reads as both
shapes, falls to the default, and must declare package-json on the node.
The sync licence — what a grant now carries
A PluginGrant IS the sync licence: the right of a registered instance to REPLICATE a package
from this registry. Its subject is a MeshWeaverInstance and its verb is sync, which is
deliberately not the user-facing entitlement — that grants a person the use of a package on their
own portal and says nothing about a deployment holding a copy of it.
Each entry carries the terms the right was issued under, so "may this instance pull this package" and "under what licence, until when, on whose authority" are ONE record rather than an ACL sitting next to a licence that could disagree with it:
| field | meaning |
|---|---|
ExpiresAt |
end of the term. Null = perpetual — which is why every grant written before licences existed keeps working unchanged. Per ENTRY, because one instance routinely holds a perpetual licence for the platform repo alongside a termed one for a paid package. |
IssuedUnderLicense |
SPDX id, resolving to a License/{SpdxId} node whose text can be shown. Null stays null — a licence nobody granted is never invented. |
IssuedVia |
an order id, a coupon code, a ticket. The audit trail. |
IssuedAt |
when it was issued. |
IsRevoked (on the grant) |
the instance-wide stop. Entries survive it, so a revocation stays reviewable — and liftable. |
Allows(source, package, now) is granted and within term and not revoked. Matches ignores
the term on purpose, so a caller can tell expired from never licensed — the two need different
remedies (renew, versus buy).
SyncLicenseService is the one writer. Issue, revoke, revoke-all, reinstate. Issuing is
idempotent — re-issuing REPLACES an entry, so renewing a term is the same call as granting it — and
an issuance with no issuing principal is refused rather than written anonymously. It records an
authorization; it does not make one: whether the licence may be issued (a global-admin gate, a
verified payment, a validated coupon) belongs to the caller, exactly as PackageEntitlement puts
its check on the action.
🚨 Before this, a grant had exactly two writers — the
DefaultGrantsregistration seed and an admin typing into the Instance-grants tab — and no way to express a term at all. That is workable for three instances and not for a catalogue: a consumer needing ONE package had no smaller thing to ask for than a standing credential to a whole repository.
Plans — the licence on the instance
The Store sells plans (free · personal · pro · dedicated — your own instance — ·
enterprise — self-hosted; the ladder and the purchase rule are the Store's own, Store/Subscriptions
in the MeshWeaver.Plugins repository), and every package names the plan it belongs to in its root's
content.tier. The instance carries its plan on its own record (MeshWeaverInstance.Plan,
#2804): a new registration starts on free (a registration key minted for a plan puts it on that
plan), and a global admin raises it in one field (Instance grants ▸ Plan). Every registry decision
— /api/plugins, /files, the bundle index and download — is then:
granted for the source/package by some entry within its term and the package's tier is covered by the instance's plan (
PlanTierRanks.CoversInstance), narrowed by the entry's own cap when it names one (Narrower).
| instance plan | may pull |
|---|---|
| (none on the record) | the baseline: packages declaring free or no tier — a record that predates the field is a free instance, not an unlimited one |
free |
the same |
personal |
… plus personal |
pro |
… plus pro |
dedicated |
everything — the tier node is flagged allAccess ("no limit on packages"), which its rank alone (25 < enterprise's 30) would not give it |
enterprise |
everything, by rank |
A grant entry says which sources and packages an instance may see — Plugins/*,
Reinsurance/UWDeepfield — never at what level. An entry may still carry a plan
(Plugins/*@free) and then acts as a cap for that source: a free-capped entry on a pro instance
licenses free packages from that source; an enterprise-suffixed entry on a free instance licenses
free. A cap can only lower, never raise — which is precisely what the previous rule got wrong:
"a plan-less entry covers every tier" let every instance registered before plans existed pull
pro and enterprise bundles, and a @pro suffix licensed pro on any instance that carried it.
A refusal by plan is said on the instance (2026-09-12, #4097)
Every registry decision above refuses a package the plan does not cover silently on the wire —
the same answer as "no such package" — and logs which it was on the registry. That is the
enumeration defence, and it stays: a registered instance must not learn what a registry carries
from the shape of its refusals. But it made one case invisible where it mattered. A fresh instance
on the free plan (the build instance, 2026-09-12) never saw the enterprise Hosting package
the registry itself declares preInstalled; the instance showed canPatch=False (pointing at
Modules:Assemblies), /health said "install the package from the registry" — which the instance
cannot do — and the Plugin Catalog had no card for it. One cause, three consequences, nothing on
the instance naming the cause.
Since #4097 the listing carries the verdict for exactly that case: a package the registry declares
in the instance's default set (preInstalled: true), from a source the instance's grant
reaches (some entry matches it, within term), that the plan refuses. It rides beside the
granted packages as refused: [{ packageId, module, requiredTier, instancePlan }]
(PluginGrant.TierRefusal → AuthenticatedInstance.TierRefusal → PluginRegistryEndpoints.ListAll),
additive in both directions: an old consumer ignores the member, an old registry never writes it.
The instancePlan is the plan the decision was taken at — the record's plan narrowed by the
reaching entry's cap — so the sentence names the plan that actually refused. A package from an
ungranted source is still absent, tier or no tier: TierRefusal answers only when an entry
reaches the package, so the default set is what the registry chose to tell the instance about and
nothing else becomes enumerable.
On the consumer the unattended default install (InstanceAutoRegistrationService) records each
refusal typed on the _DefaultInstallLedger and keeps one marker per refused module in step with
the registry's answer of that boot — modules/activation.d/<Module>.tier-refused
(ModuleActivationSidecar.SyncTierRefusals), cleared by the next pass in which the registry no
longer refuses it (a plan upgrade). A pass in which some source's listing FAILED moves nothing —
neither the ledger's refusals nor the markers — because a registry that was down at boot has not
lifted anybody's plan. ModuleActivationSidecar.Read folds the markers onto
ModuleActivationList.TierRefusals, which is how the verdict reaches every surface that used to
name the consequence without a new parameter on a host compiled against the previous platform:
| surface | before | since #4097 |
|---|---|---|
/health required_modules (RequiredModuleStatus.Classify) |
"store-delivered and NOT installed on this instance — install the package from the registry" | "⛔ Not installed on this instance: Hosting needs plan tier enterprise, this instance is on free. Installing it from the registry is not possible on this plan — raise the instance's plan on the registry, or delist it from Modules:Required" (still ExpectedLater: no rollout changes a plan) |
the package card (CatalogLayoutAreas.BuildCard) |
no card | the card, with the same sentence localized (ui.packageRefusedByPlanTier) and no Install button; after a plan DOWNGRADE an already-installed package says installed-but-no-longer-covered (ui.packageInstalledAboveThisPlan) and loses its Update button |
the activation report (ModuleActivationReport.TierRefused, /health's activation line) |
nothing | the refused modules, named |
the self-updater's startup line (SelfUpdateHostedService.StartAsync) |
canPatch=False and "list MeshWeaver.SelfUpdate.Aks under Modules:Assemblies" |
canPatch=False (⛔ Not installed on this instance: Hosting needs plan tier enterprise, this instance is on free. The Kubernetes patcher ships in that package …) |
The vocabulary is #4091's binding-conflict sentence ("⛔ Not installed on this platform: it needs YamlDotNet 18.1.0.0, this platform provides 16.3.0.0") with the nouns of a plan.
The ladder is the Store's data, not a table in the platform. The registry reads its own
Admin/Tiers/{id} nodes (content.rank, content.allAccess) once a minute (PlanTierLadder) and
hands the snapshot to the authenticated caller, so every surface decides the same way and there is
no copy of the Store's PlanTiers to drift. The rules at the edges are deliberate and never widen:
- a plan the ladder does not know — a typo, or a plan this registry never seeded — reads as the
baseline: free and untiered packages, nothing above. "Nothing at all" would not be safer, it
would be a portal without its Store; the fail-closed property that matters is that an unknown id
cannot license MORE. A registry with no tier nodes at all (a local self-registry, the e2e stub)
therefore serves its free and untiered packages to every instance and refuses every paid tier —
freeranks at the baseline by definition, ladder or not; On a registry that HAS a ladder, deciding at the baseline is never silent (#5894): the registry logs a warning naming the instance, the stored plan and the plans it does know on every resolution of that instance (a registry with no tier nodes at all is not reported — every plan is unknown there by construction, and the ladder read says so once), and a plan-tier refusal reports the STORED id, in canonical form (trimmed, lower-cased), asinstancePlan— "this instance is on gold", never "this instance is on free" for a record that says something else. (An unknown cap on a grant entry is still reported as the baseline: it is neither the instance's plan nor an upgrade target.) Measured on the public registry: a dedicated client instance storedsme, a plan renamed todedicatedwhose tier node had been deleted, and its ledger listed 18 refusals — Mail and Teams among them — every one reading "this instance is on free"; - a retired plan id resolves to its successor, explicitly:
PlanTierRanks.RetiredPlansis a closed table of renames (todaysme→dedicated), applied byPlanTierRanks.Canonical, so a record still storing the old id stands on the successor and a promotion to the old id writes the new one. The registry logs a warning for every resolved instance that still stores a retired id, and a tier node still carrying one is ignored by the ladder (its successor's own node ranks it). Only a RENAME belongs in that table; a withdrawn plan has no successor and stays unknown; - a tier node cannot be deleted while an instance stands on it (
TierInUseDeletionGuard, #5894). The guard counts, as System, every registered instance whose plan resolves to the tier (a record with no plan stands onfree), refuses the delete naming them, and refuses it too when the census itself fails — an unknown count is never zero. Deleting theAdmin/Tierscontainer is refused while any instance is registered. A node carrying a retired id is used by nobody and stays deletable. Move the instances to another plan on Instance grants ▸ Plan first; - a package tier the ladder does not know is covered by nothing;
- a caller that does not know the package's tier (the tier-blind
Allows(source, package)) is never answered by a plan-scoped entry — otherwise every plan would be all-access at exactly the call sites that forgot to ask.
One asymmetry with the Store's purchase rule is deliberate: a package that declares no tier (Store, Agents, Skills, Essentials) is the platform baseline and is covered by every plan. For a person buying a package "no tier" means "not sold under a plan"; for an instance replicating the registry it means "ships with the platform", and a Pro instance without the Store is not a smaller portal, it is a broken one.
Promotion takes effect on the next request. InstancePlanService.SetPlan writes the field and
tells the process's InstanceRegistryAuthenticator to forget its cached verdict for that instance;
other replicas follow within the cache minute. PluginBundlePlanTest pins both halves — a legacy
plan-less grant is capped at free, and a promoted instance pulls its pro package on the very next
request.
The instance lookup has ONE bound, the query's. The Plan form finds the record by id
(InstancePlanService.FindInstancePath, a declared mesh-wide listing read as System) and waits
for the fan-in's merged Initial, which faults at its own budget
(MeshOperationOptions.QueryInitialBudget, 15 s on the default ladder) with a
QueryProviderStalledException naming the provider. The lookup used to carry a 10 s timer of its
own, written before the fan-in had a bound: a second, shorter bound on the same wait, so the form
gave up while the query was still inside its budget (#5894, the inverted ladder of #1198). It, the
two lookups in MeshWeaverInstanceService and PlanTierLadder.Read now carry none
(InstanceLookupHasNoBoundOfItsOwnTest).
Only a global administrator sets a plan, and the write runs as System. An instance record lives
in its registrant's partition ({owner}/MeshWeaverInstance/{id}), and a global administrator is a
platform admin, not a data superuser — under the admin's own identity the read of another user's
record is refused. SetPlan therefore checks hub.IsGlobalAdmin() first and then reads and writes
the record as System, the same shape as RevokeKey; any other caller is refused with
UnauthorizedAccessException before anything is read. InstancePlanSetByGlobalAdminTest pins both
halves on a mesh without the default public admin grant: an admin whose only grant is Admin on the
Admin partition promotes an instance another user registered, and a plain member is refused with the
plan unchanged.
A registration key carries the plan. Mint it for a plan (Instance grants ▸ Registration keys ▸
Plan) and every install that registers with it lands ON that plan and is seeded one plan-less
<source>/* entry per configured source, on top of DefaultGrants — "a key for Pro customers" is
minted once and never typed per instance. A key with no plan seeds the DefaultGrants alone and
the instance starts on free. Fetching a source's sealed publication whole
(/api/plugins/bundles/prebuilt/…, the node-repo CI gates) still needs a plan-less <source>/*:
the publication carries every plan's bundles.
Open registration — the free tier by default. A local install (memex-local registry https://registry.example.com, no key) registers with no bootstrap key at all. The registry
accepts that only where its operator minted a registration key for the plan un-keyed callers enrol
into — free on the plugin registry instance — and configured it as PluginCatalog:OpenRegistration:Key
(the chart's secrets.memex_portal.PluginCatalog__OpenRegistration__Key). The registration then runs
exactly as if the caller had presented that key: owned by its minting admin, on the free plan,
seeded <source>/*, revocable by revoking the key. Everywhere else an un-keyed registration is
refused with the same 401 an invalid key gets, and the caller never learns which. Raising an
instance to a higher plan is a platform admin's edit of the instance's plan on the registry — never
something the instance can ask for. InstanceOpenRegistrationTest pins the closed default and the
plan the registration lands on.
Short-lived tokens — POST /api/instances/token
An instance exchanges its durable mwi_ key for a short-lived, scoped JWT access token:
POST /api/instances/token
Authorization: Bearer mwi_…
{ "scope": ["Plugins/Publish"], "lifetimeSeconds": 900 }
→ { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6…", "tokenType": "Bearer", "expiresIn": 900,
"scope": ["Plugins/Publish"] }
Both body fields are optional; an empty body means "a default token for everything I am licensed
for". The response's scope is the EFFECTIVE scope — what was asked for intersected with what is
licensed — returned explicitly so a consumer sees it got less than it requested rather than
discovering it one 404 at a time.
Three properties, and each is what makes the token safe to hand out:
- Identity and scope, never authority. The live grant is re-read on every request, so revoking a licence takes effect at once instead of surviving until the token expires.
- It can only narrow. A token minted for more than its licence covers grants nothing extra — scope is an additional filter, never an alternative source of permission.
- A token cannot mint its successor. Only the durable key may exchange; otherwise a
minutes-long credential becomes perpetual by renewal. The check is on the credential's SHAPE
(
mwi_vs JWT, disjoint by construction), not on a claim the presenter could edit.
The token is signed, not stored — minting writes no node, so there is no per-issue write amplification and no expiry sweep to maintain. That is only safe because authority is re-checked on use. It carries the hash of the key it was exchanged from, which is how it routes to its instance through the same index the raw key uses, and which makes re-issuing an instance key invalidate every outstanding token for free.
The signing key is a mesh node, minted once
The HMAC key lives at Admin/SyncTokenSigningKey/current — one node per registry,
enc:-protected at rest by the same envelope as PluginRegistryCredential. Not configuration: a
signing key has to be IDENTICAL on every replica (a token minted on pod A must verify on pod B) and
should never pass through a human's hands. Configuration achieves the first only if an operator gets
it right in every environment, and fails the second by construction. It is minted on first use, so
there is nothing to provision.
🚨 Uniqueness is the NODE's, and the RESPONSE cannot be trusted to report it. Measured
2026-08-18: under a genuine concurrent create, both callers are told created=1, existing=0 —
the response's exists-check lags, exactly as IMeshService.CreateOrUpdateNode's remarks warn —
while storage keeps the FIRST create and discards the second. So the store enforces uniqueness
and the response does not describe it. The rule that follows is absolute: create, then read back,
and sign only with what is actually stored. Signing with locally minted material is precisely how
two replicas end up on different keys. A lock would not help — it cannot span pods.
Minting happens only AFTER the caller authenticates, so an anonymous request cannot provoke a node write; the verify path reads without minting (a token cannot verify against a key created after it was signed anyway).
Rotation keeps the outgoing key. RotateAfter on the node is the due date — data, so it is
visible and adjustable without a deploy. A rotation writes a fresh key and moves the old one to
ProtectedPrevious, verifiable until PreviousValidUntil; verification tries current, then
previous. The window is exactly one maximum token lifetime, because nothing signed before the
rotation can still be unexpired after that. Without it, rotating would invalidate every token in
flight mid-run.
⚠️ Rotation is not yet automatic. Reminder/ReminderSchedule — the platform's durable,
claim-guarded recurring trigger, which is the right home for this — exists as contract plus unit
tests with no runner: no registered node type and nothing that reads reminder nodes. So the due
date is recorded and reported, and rotation is invoked explicitly. When the reminder runner lands,
registering a recurring reminder against this due date is the whole of the remaining work; nothing
here needs to change. Deliberately NOT filled in with an opportunistic "rotate when someone
notices it is due" — two replicas rotating at once would each retire the other's key.
Licence acceptance — enforced, not merely recorded
LicenseContent.RequiresAcceptance is enforced on the install path, beside the entitlement check
and for the same reason: on the ACTION, so the unattended paths (default install, the update
watcher) are gated identically to a click. A permissive licence asks nothing and gates nothing —
Apache-2.0 and MIT install exactly as before.
The LicenseAcceptance record's body hash is checked, not merely stored: an acceptance given
against earlier terms does not satisfy revised ones, which is the entire reason the record carries a
hash. Normalization folds line endings and trailing whitespace — what a round-trip through git or an
editor changes — and nothing else, so consent is never revoked by a difference no reader could see.
A licence the catalog does not hold (including every SPDX expression, like Apache-2.0 OR MIT,
which names a choice rather than one node) demands no acceptance: terms the platform cannot display
cannot meaningfully be consented to. That is a consent decision, never an access one — what a caller
may install is decided by PackageEntitlement and, for an instance, by its sync licence.
The consumer — the Store's catalog area, not a Space
On every installation the catalog is a layout area (CatalogLayoutAreas), rendered as the
Overview of a PluginCatalog node — browsing and provisioning is the Store's job. It is not a
browsable Plugins Space: a Space partition would (correctly) deny read to everyone else — the very
"Access denied on 'Plugins'" a Space produced.
The platform-admin Settings ▸ Administration ▸ Plugin Catalog tab that used to consume this same rendering was retired. What remains under global settings is the read-only installed inventory on the About tab (
CatalogLayoutAreas.ObserveInstalledManifests). Install / Update / Remove actions still gate onhub.IsGlobalAdminwhere they need to.
The catalog reads PluginCatalog:RegistryUrl (e.g. https://registry.example.com) — or, to consume
several registries, a PluginCatalog:Registries list of { Name, Url, Ref, Token } entries,
rendered as one titled catalog section each. It lists each registry's packages via
RegistryPackageSource (an IPackageSource over HTTP, on the mesh's Http I/O pool), and joins them
against this instance's install registry — the Package nodes under the Plugins partition — to
render Install / Update / Installed per module.
On a deployed instance the single URL is declared on its deployment record — registryUrl on
Hosting/Deployment (DeploymentContent.RegistryUrl), trimmed and without a trailing slash — and
every renderer delivers it from there: the Helm values' pluginCatalog.registryUrl, the operator's
boot catalog file and the Aspire portal keys, all as PluginCatalog:RegistryUrl. The named list is
the record's consumer pluginRepos mounts. A registry may name itself here, and that is
deliberate, not leftover configuration: it makes the registry a consumer of its own /api/plugins,
so the update reconciler, the Store's registry section, module refresh and the pin audit act on it
exactly as on any other instance. The token half (PluginCatalog:RegistryToken) is a credential and
comes from the vault only — never from the record. Before the field existed the URL lived only in a
hand-edited Helm overlay, so a re-render from the record dropped it and the deploy-drift sentinel
reported it as never delivered (Memex#116).
Categories first, packages per category
🚨 The catalog must not load the full thing — only categories first. (maintainer, 2026-09-03, after
/Storeon the cloud took minutes to paint.)
The page a visitor lands on lists the source's categories — one tile per
PackageManifest.Category with its package count, plus an All packages entry — and reads
nothing but the source's manifest listing to do so: no install record, no permission
evaluation, no activation-state read. Picking a tile (?category=…) renders that category's cards
and joins only its members against the install registry — one exact-path read per member,
batched into one request (CatalogLayoutAreas.InstalledRecordQueries), never the registry's whole
children listing. An install record carries the package's whole installed-file baseline, which is
exactly the payload a page rendering one category must not load for every package on the instance;
the click's dependency closure gets the installed ids from a select:-projected listing that
leaves the content column out of the read. The flat list stays reachable behind ?all=true, and
it is the one page that can show the orphaned records below. The pure seams a test pins this with
are CatalogLayoutAreas.Plan (the landing's card set is empty by construction) and
CatalogLayoutAreas.Categories.
Two findings from the same investigation, recorded so the next reader does not re-derive them:
/Store— the storefront every user sees — is not this view. It is in-mesh source in MeshWeaver.Plugins (Store/Catalog/Source/StoreCatalogLayoutAreas.cs). It already opens on a category grid, but it still derives that grid from the full mesh-widenodeType:Store/Pluginsynced feed (content included, plus one per-node Read probe per subscriber inSyncedQueryDataSourceExtensions.FilterByReadPermission) before the tiles can paint; itsStore/FirstFrame.mdkeeps the feed off the first frame but does not make it cheaper. The same rule applies there: a landing needs aselect:shell projection (category, name, icon, order — every one aMeshNodeshell field) or a category summary written once by the owning hub, and per-viewer facts only for the category actually opened.- A
path:a|b|calternation is not yet a live query. The storage provider derives ONE change-feed scope filter per query string, keyed on the FIRST path, so a change underborcnever re-runs it. The multi-Queriesrequest (onepath:per member) gets a scope filter per query and is what the category page uses; a synced consumer that needs the single-statementIN (…)form also needs that relevance rule widened to every path first.
Installing
An admin clicks Install (or Update). No GitHub credential is involved on the consumer:
POST /api/plugins/files {id}on the registry → the package's folder files.PackageInstaller.Installon the consumer parses the files into MeshNodes and upserts them — a Content package imports its folder into the target partition; a Code package synthesizes itsNodeTypenode from the manifest'snodeTypeConfiguration, imports itsSource/*.csas Code children, and requests a release so the mesh compiles the type live. No app rebuild, no NuGet.- An install record (a
Packagenode) is written under thePluginspartition so the tab flips the card to Installed and can offer Update when the registry's version moves on.
Re-installing is an upsert (create-or-update by path); installing one module never disturbs another in a shared partition.
A default package the boot could not deliver is named, with its cause
The boot's unattended default install (InstanceAutoRegistrationService) steps over a package whose
install throws — one unreachable package must not withhold the rest — and that is only safe because
the skip is never silent:
- Named, with its cause. The pass's summary line carries
FAILED: [<id>]andCAUSES: [<id>: <exception type>: <message> ← <innermost type>: <message>], andPlugins/_DefaultInstallLedgerrecords the same pair underfailureCausesbesidefailed(MeshWeaver#5826). Before, the ledger saidfailed: ["Anthropic"]and nothing else; the cause — a registry file fetch whose three 30 s attempts all expired waiting for headers — lived only in a separate per-package log line. - Exactly one cause per failed package, derived from the failures. The ledger and the summary
line take their causes from
DefaultInstallSummary.CausePerFailure(): a failed id whose recording site supplied no cause gets an entry that says "no cause was recorded …" rather than nothing, and a cause for a package that did not fail is dropped. One site records failures today (the per-package catch); this keeps a second one from re-introducing a failed id with nothing beside it. - Kept off the seeded list, and retried by the next pass (#2254) — the ledger knows the package, so the governed-provision hold does not take it for a newly listed one (#5934).
- The next pass does not wait for the next boot (#5825). A boot install fails because a registry
did not answer, and the registry answering is the event that changes the outcome. The reconciler
already observes it: every reconcile that a feed read after boot made possible (the safety-net
pass on
PluginCatalog:ReconcileSafetyNetInterval, and a boot reconcile that was deferred and is drained by the first feed read) ends by asking the installer to run the default install again when its last pass left something undone — a failed package, a source that did not answer its listing, or the pass itself faulting (RegistryUpdateReconciler.RetryFailedDefaultInstalls→InstanceAutoRegistrationService.RetryFailedDefaults). A source HELD at boot (a torn or unreadable seal) is not owed a repair and is not listed again by one: a hold releases at the next process start and nowhere else, so the repair skips it and keeps that contract (the maintainer's decision on #6493). It still makes the pass's listing incomplete, which is a statement about what the pass saw (ListingIncomplete), kept apart from the retryable half (SourceUnreachable). It runs on the reconciler's serialized lane, so there is no second writer and no timer of its own; it is the same shape as the faulted-reload retry that ends the same pass. A pass that left nothing undone is not repeated (no source is listed), and the boot reconcile does not ask, because it runs straight after the pass that failed. A package that fails again stays recorded and logs its cause at Error on each attempt, so one that can never install stays loud at the reconcile's cadence. Before this the only repair was the next boot: measured on the control instance on 2026-09-27,Anthropicstayed uninstalled after one stalled HTTPS read until the next image roll. With the safety net disabled (ReconcileSafetyNetInterval≤ 0), the next boot is again the retry. Pinned byAFailedDefaultInstallIsRepairedWhenTheFeedAnswersTest. - A snapshot.
failedandfailureCausesdescribe the last pass that attempted anything: an entry leaves the moment its package is delivered or stops being declared. - A bound that expires says which wait it was. The install chain's internal bounds fault with
Install leg timed out after 30s: <what was awaited, with its path>instead of Rx's bare "The operation has timed out.", which named no leg (the 2026-09-23Storesample on #2254).
The registry side of POST /api/plugins/files reads only the package's folder (a narrow git fetch),
never the whole repository — see Registry Listing Cache.
Bundle bytes from the registry
A package's compiled bundle — the <package>.zip a bake produces, with its NodeType assemblies
and its module section — reaches a consumer by one of two routes, and the registry's index says
which. Both indexes carry an artifact per entry:
GET /api/plugins/bundles/index.json— each bundle carriesartifact: the digest-addressed referencecr.meshweaver.cloud/plugins/<source>/<package>@sha256:<manifest digest>of the bundle in the fleet's container registry, ornull.GET /api/plugins— each package carries the sameartifact, stamped besidesource; the shape stays{ packages: [...] },storageTypeincluded, so the first-run setup wizard reads it unchanged.
The registry renders the field from its record of pushed publications
(IPublicationArtifacts, MeshWeaver.PluginCatalog) — a seam a host fills once its bake lane
records pushes; the platform default (NoPublicationArtifacts) records nothing, so a registry that
has pushed nothing renders null everywhere and behaves exactly as before.
The consumer, PluginBundleClient, branches on the field:
artifactpresent — it fetches the manifest by digest and the bundle'sapplication/vnd.meshweaver.bundle.v1.ziplayer by digest from the OCI registry, through the sharedOciRegistryClient, which verifies every byte against the digest it was fetched under. The credential is the plugin-registry token the client already holds for the registry that advertised the artifact, presented asBasic instance:<token>at the realm the registry's401challenge names; the registry edge validates it at that registry'sPOST /api/instances/token. Bytes that do not hash to the sealed digest are refused: nothing lands, there is no HTTP fallback, and the adoption ledger recordsArtifactRefused. A transport failure is the miss it always was (FetchFailed): logged, counted, the consumer compiles or keeps what it has.artifactnull — it takesGET /api/plugins/bundles/{plugin}/{version}exactly as before.
Either way the bytes enter the same landing path — ModuleLandingService, the link probe at
placement, PrebuiltAssemblySeeder's identity rule — so where the bytes came from changes nothing
about whether they may load. OciRegistryClient is also what the self-updater's OciTagLister
lists image tags with, so an installation speaks to the fleet's registry through one client, with
one credential, for images and bundles alike. The design is
Plugin Bundles in the Registry.
Free syncs freely, commercial needs a Global Admin, a governed activity or the deployment configuration
Who may bring a package onto an installation is decided by its price, and the decision is made on the action, not on the screen that triggered it:
| Package | Access / sync |
|---|---|
Free — price null or 0 |
Installs and auto-updates with no special permission. This is what lets a fresh installation pick up the platform baseline unattended. |
Commercial — a non-zero price (positive = purchasable, negative = coupon-only), or a sales contact (contactEmail) |
Requires Global Admin on the installing instance, a verified governed package.provision activity signed for the package (or a package.provision-set activity whose signed list names it), or the deployment configuration naming it exactly in PluginCatalog:InstallByDefault (for the unattended default install), to install or update. |
PackageEntitlement.Authorize is the single rule, and it runs inside PackageInstaller.Install /
InstallNodeRepoDelta — so the machine paths are gated exactly like a click:
The catalog click captures the clicking user before the install's system impersonation and passes it as the authorizing principal (the install itself must run as System — it is provisioning).
The install record remembers the authorizer (
Package.authorizedBy), and the update watcher re-verifies that principal before applying a commercial delta: a PERSON must still be a global admin, so revoking the admin stops the syncing; a governed ACTIVITY must still verify for this package (below).A governed activity is an authorizing principal. Installing a package on an instance goes through a governed
package.provisionactivity signed by people (or, for several packages at once, onepackage.provision-setactivity whose signed list names them), not through a person's standing rights, so the Store's provision control plane hands the activity (Governance/Activities/{id}) over as the principal once it has verified the request. The gate does not take the path on trust: it reads the activity authoritatively from storage (ReadGovernedActivity— the same identity-independent read the broad-grant guard uses, so core needs no reference to the Governance package) and admits only when all of these hold (PackageEntitlement.WhyNotAuthorizingActivity):check refused when the path it is not exactly Governance/Activities/{id}, or nothing readable is stored therethe node type it is not a Governance/Activitythe standard it is neither package.provisionnorpackage.provision-setthe state it has not started — Executing,DoneorFailedare started (gates green, signatures consumed; monotone, so a later update re-check gives the same answer)the signatures none is consumed the package the signed packageinput is not this package's id (ordinal)the package (a set) for package.provision-set: the signedpackageslist does not name this package's id (ordinal). The list is parsed byPackageEntitlement.PackageSetIds, the Store's rule: ids separated by commas, semicolons or whitespace, each a plain id. A list holding an id that is not plain, or the same id twice, names nothingpackage.provision-setis ONE activity, signed once, that installs a list of packages (policypackage-set-one-signature-round). It has the same authority and gates as the single standard, and the Store installs the list in dependency order.A refusal names which check failed. An admission is logged as
[PackageEntitlement] commercial package X AUTHORIZED by governed activity Governance/Activities/…, and the activity is stamped on the install record asauthorizedBy— the record says which activity authorized it, and the unattended update re-verifies that same activity. Only WHO may authorize widens: the registry still serves only what the instance's plan tier covers, and the licence and parameter gates still run.The deployment configuration is an authorizing principal — see the next section. It is the ONLY principal the boot-time default install carries, and only for a commercial package named exactly in
PluginCatalog:InstallByDefault(or required by one that is). Every other unattended path carries no principal, so a commercial package cannot ride in on a wildcard — it fails closed: held if newly listed under a whole-source pattern (no ledger entry), otherwise recorded on the ledger underskipped.A refusal is never silent: it logs a speaking reason and, on the auto-update path, raises a notification on the install record. The manual Update button stays.
The deployment configuration authorizes what it names
Policy deployment-config-authorizes-named-commercial.
PluginCatalog:InstallByDefault lives in the instance's deployment record, and that record
changes only through a reviewed pull request in the deployment repository, merged by the auto-merge
App — a merge there is the release. Naming ClientA/Claims there is therefore a decision people
took, reviewed and put on record: the same kind of decision a governed package.provision activity
records. So the unattended default install carries an explicitly named principal for it,
deployment-config:{PluginCatalog:InstanceId} (DeploymentConfigAuthority), and
PackageEntitlement.Authorize admits a commercial package under it when
(DeploymentConfigAuthority.WhyNot):
| check | refused when |
|---|---|
| the instance | the principal names another instance than this one's PluginCatalog:InstanceId |
| the naming | the package is not named exactly (Source/Package) in the CURRENT InstallByDefault — a whole-source Source/* names nothing — and is not a requirement (direct or transitive) of a package that is, as this boot's selection computed it |
| the source | the candidate manifest names no source — a package id alone never authorizes, because another source may list the same id. A registry stamps the source on what it serves; an update listed straight off a watched repository does not, and is applied by a person's Update click |
The principal is verified, never trusted by its string: the gate re-reads the current configuration,
so the install record's authorizedBy: deployment-config:… is re-verified by every unattended update,
and a package the configuration stops naming stops being authorized by it. An admission is logged as
[PackageEntitlement] commercial package X AUTHORIZED by deployment-config:… : named in PluginCatalog:InstallByDefault as 'Source/X', the boot logs the same, and the default-install ledger
records it under authorized — {package, installedBy, reason, installedAt} — beside seeded.
What does NOT change:
- Installing grants nobody anything. Access to the installed package still follows the plan
(
Store:InstancePlan) and the subscriptions — the Store'sPluginGatekeeps its content dark until a user's plan admits them. No sweep, no bulk grant, no notification. - Wildcards still never pull in a commercial package, newly listed packages under
Plugins/*are still held, and a commercial requirement of a wildcard-selected or baseline package is still not pulled in. - Seed once. The ledger still records the package as seeded, so an operator who removes it is not fought by the next restart.
- The registry's plan tier still wins (a package the plan does not cover is not served), and a
licence that requires acceptance is still skipped: a configuration cannot accept terms on a
person's behalf (
LicenseAcceptanceGate).
The registry side needs nothing extra: /api/plugins already requires a registered instance key and
scopes every listing and file fetch to that instance's PluginGrant, so an unauthenticated caller
enumerates nothing at all — commercial or otherwise.
Removing an orphaned install record
Plugins/_Policy caps create/update/delete at false for every caller — a platform admin
holding an Admin assignment on the Plugins partition included. That is correct: install records are
written only by PackageInstaller, under system impersonation.
The consequence used to be a dead end. When a package leaves the registry — a course folder renamed,
so it becomes a new product id — its record Plugins/{oldId} had no card (cards come from the
registry's package list), hence no Uninstall, and no user identity could delete it. It rendered
publicly forever as a phantom "installed" product.
The catalog therefore also lists install records the source no longer offers, with a removal
action for global admins that calls PackageInstaller.RemoveInstalledRecord — the same
system-impersonated identity that wrote the record. Two properties matter:
- The list is computed only against a non-empty available list. An empty one means "the registry offers nothing" and "listing it failed" indistinguishably, and an unreachable registry must never offer to remove every record.
- Removing the record does not delete the content it installed — that is a separate partition lifecycle.
Why this shape
- Credential encapsulation. GitHub access lives on exactly one instance. Onboarding a new
installation is "point
PluginCatalog:RegistryUrlat the registry," not "provision it a GitHub App." - Not a Space. The catalog is a layout area reading a remote registry; there is no partition for a non-admin to navigate into and be denied.
- Registered + curated. Only registered instances (their
mwi_key, scoped by an admin-ownedPluginGrant) can read, and only published plugin folders are exposed — a<Plugin>/index.jsonStore/Plugin root, or apackage.jsonon apackage-json-format source. The catalog lists real modules (Publish, Edu, …), not every partition that happens to define a type. - Capability, not data. A package ships its
NodeType/Code/content folder — never a partition's user data — so installing a plugin gives you the types and their code, not anyone's records.
Relationship to GitSync
GitSync is how the registry itself reads plugins — the registry's IGitHubRepoClient fetches the
plugins repo with its one credential. The registry adds the fan-out: git → registry (credentialed,
once) → many installations (credential-free, over HTTP). See Plugins for
the node-native plugin model and Static Repo Import for the
import pipeline both paths share.