The package feed and its health
The store's catalog is a live feed, not a stored list. Every configured package source — a git
repo of package manifests, another MeshWeaver instance's registry, or a node-repo checkout MOUNTED on
this host's disk — is polled every
5 minutes, and the union of what they answer is what /Store shows and what
@Store/data/StorePackage returns.
The degradation is deliberate
A poll pass can fail: GitHub can time out, a registry can answer 503. When that happens the feed keeps the last good snapshot for that source. That is correct — a store must not empty itself because one source blinked, and a source that recovers is picked up on the next pass with no intervention.
…but it used to be invisible, and that was the defect
Before #1180 the feed could not tell three different things apart, because all three produced the same "nothing to report" marker:
| What actually happened | What the feed recorded |
|---|---|
| The head has not moved — nothing new to fetch | nothing to report |
| The pass blew up with a transient error | nothing to report |
| The pass blew up because the credential is rejected | nothing to report |
So on the public instance every git poll answered Octokit.AuthorizationException: Bad credentials, for the entire readable window, while @Store/data/StorePackage kept returning 132
packages and the store looked completely healthy. The catalog was frozen at whenever the
credential last worked; a package published after that simply did not appear, and the only trace
anywhere was a warn line every five minutes.
What the feed reports now
Each source carries a StoreFeedStatus row — published as a second virtual collection beside
the packages, from the same poll passes (never a second poll loop):
| Field | Meaning |
|---|---|
source / kind / target |
which source this row is about (git, registry or mount; the target is the URL, or for a mount its slug — never the host path, because the row is readable by everyone who can read the Store, Anonymous included; the configured path is in PluginCatalog:Sources and in the log) |
consecutiveFailures |
how many passes have failed in a row — zero after any success |
lastSuccess |
UTC instant of the last pass that ANSWERED (an unmoved head counts) |
lastFailure, error |
when it last failed, and a short bounded description |
fault |
None · Credential · Missing · Transient · Mount |
degraded |
whether this is a reported condition |
verdict |
one sentence: what is wrong, how stale the catalog is, and whether retrying can help |
The signal is PERSISTENCE, never one failure
A source becomes degraded after 3 consecutive failed passes (15 minutes). One failed poll is
normal, and serving the last good snapshot is the right answer to it — a signal that fires on one
bad minute is a signal people learn to ignore. The fault kind changes the wording and the log
level; it never changes the threshold — with ONE exception, the Mount fault below, which is
classified by its own exception type rather than guessed from text.
A permanent fault SAYS retrying will not help
Bad credentials, a 401, a non-rate-limited 403 and a 404 are permanent: every further pass
fails identically, and the verdict says so in those words. Telling somebody to "retry shortly" there
sends them round a loop that cannot close.
🚨 A rate-limited 403 is transient and is checked first. GitHub answers 403 both for "this identity may not read that" and for "you have asked too often" — and reading the second as a dead credential would send an operator to replace a credential that is perfectly fine, which is the one false positive that destroys trust in a signal like this.
A broken MOUNT is degraded on its first pass (Plugins#1563)
A mount whose path is missing or unreadable on this host is the Mount fault: permanent, and
degraded on the first pass, because a path that is not on this disk is operator configuration
that no later pass will find. It is deliberately not Missing — that remedy is about a remote
source's URL or the polling identity's access, while this one is about the mount or the configured
PluginCatalog:Sources:N:RepoPath. 🚨 The listing PROBES the path and fails: core's local directory
reader answers a missing root with an empty snapshot and no exception, and the health fold reads "no
exception" as success, so leaning on it would have turned a broken mount into a row reading ✅ live
with zero packages — worse than no row at all.
Frozen is not the same as never loaded
If a pod restarted after the fault began, the source has no last-good list at all: its part of the catalog is missing, not stale. The verdict distinguishes the two, because "you are seeing an old list" and "you are seeing none of this source's packages" call for different reactions.
Where to read it
/Store/Catalog/FeedStatus— a table of every source, its state, its last successful poll (rendered in your time zone) and its verdict. Global admins only: it names repositories, registry URLs and raw failure text.- The store's own page — when a source is degraded, an admin opening
/Storegets a banner saying the catalog is stale, on the page that is serving the stale list. It says WHY per kind and claims no history a source does not have: a polled source is named as having failed for at leastDegradedAfterpasses, while a broken mount — degraded on its first failed check — is named as unreadable on this host. The banner and the no-source page are chrome, in the viewer's language (StoreTexts, English and German). @Store/data/StoreFeedStatus— the same rows, for a health check.- The portal log — a failing pass below the threshold stays a
warn; a degraded source logs an error carrying the verdict, every pass, until it clears. Recovery is logged too.
🚨 Zero rows is three different answers, and only one of them is a wait
A StoreFeedStatus row exists per SOURCE, so an installation that reads no source produces no
row — ever. The FeedStatus page used to render that as "No package source has reported yet",
which is a waiting sentence for a permanent state: on a mount-only install it is
indistinguishable from a first poll still in flight, for ever, on the one configuration that can
never recover on its own — and it sat under the same page whose non-empty form shouts that the
store is serving a stale catalog. The page therefore reads the effective source list
(StoreManifestSource.Sources, the same list the feed polls) as a separate input from the
rows, and says which of three things is true:
| rows | readable sources | what the page says |
|---|---|---|
| none | not read yet | Reading this installation's package sources… |
| none | 0 | 🚨 This installation reads no package source — nothing polls, so this page cannot go green or red on its own; where to look is named |
| none | ≥ 1 | No package source has reported yet — a genuine first pass in flight |
🚨 The count is what will be POLLED, never what is DECLARED. Both readers are optional and
independent — a consumer instance registers no IGitHubRepoClient and reads from a registry, the
registry instance reads git — and FetchFeeds drops a source whose reader is absent, so that source
produces no SourceFeed and therefore no status row ever. Counting declarations would put a
consumer instance with only git sources back on the waiting sentence permanently, which is the same
defect one configuration over. StoreManifestSource.Readable is the ONE predicate, used by the feed
and by the page (through its hub half, ReadableSources), so "what this deployment declares" and
"what gets polled" cannot drift into two answers.
A mount-only install no longer lands here: since Plugins#1563 a configured mount IS a readable
source (the next section), so it has a row of its own — healthy, or Mount-faulted when its path is
broken.
Which sources are live — the supply-chain rule
StoreManifestSource.Resolve decides the EFFECTIVE source list from three inputs, and it is a
statement about the deployment's supply chain rather than about the Store node's content:
- the node's declared
packageSources, blanks dropped; - plus a registry source for every registry the deployment configures
(
PluginCatalog:Registries/RegistryUrl) that the node does not already name; - plus a git source for every remote git repository the deployment configures
(
PluginCatalog:Sources:N:RepoPath+:Ref— what a Deployment record'spluginRepos[]entry withisRegistrySource: truerenders) that the node does not already name, however the URL is spelled (case, a trailing slash,.git); the node's own entry wins when both exist; - minus every git source — declared or configured alike — when the installation has no GitHub App identity and another supply (a registry or a local mount): the fetch would go out anonymous against a private repo and can only 404;
- plus a mount source for every LOCAL path the deployment configures
(
PluginCatalog:Sources:N:RepoPathnaming a path, as core'sPackageSources.IsLocalCheckoutdecides), always — it is read off this host's disk and needs no identity.
The mount reader (Plugins#1563)
Before rule 5 a mount could only subtract (rule 4): a mounted package that was not installed yet was
offered by neither arm of the landing page, and a broken mount had no status row. The mount is now
the feed's third reader, MountPackages (Store/Publishing):
- It lists EVERY package root in the mounted tree — each top-level
{package}/index.jsonof an accepted root type, through the sameNodeRepoPackageSourcelisting the registry serves. Design choice: the store offers what is available; what a deployment installs by default is the platform's separate decision (InstallByDefault), and folding it in would hide exactly the not-yet-installed packages this exists to offer. - It reads only the listing's inputs — each package folder's
index.jsonandmanifest.lock— never the whole working tree, which on a developer checkout carries build output and is re-read on every poll. - Its checksum is a CONTENT key (
MountPackages.ContentKey, a hash over every card and install field of what it listed), never an mtime: a touched or re-checked-out tree with no content change never re-emits the catalog, and a real edit to a card does. - A mount card provisions from the mount: its install request carries
MountPath, and theInstall from local mountphase reads the package folder off the disk and hands it to the platform'sPackageInstaller, exactly as the registry phase does. A compiled module is not landed from a working tree (it carries no sealed bundle); the content and the install record are.
Configuration, not node content, is what survives: an unclaimed Store node is re-imported from the
shipped Store/index.json on the next Store update, so an edit there is undone. Rules 2 and 3 are
what let a consumer instance work with no per-instance edit — and rule 3 is how a client-private node
repo, mounted on ONE instance through that instance's record, gets its Store card there without ever
being listed on the public registry (whose registration seed grants every source it configures).
The feed logs the effective list once per change:
[StoreManifestSource] N package source(s): … (git identity, configured registries, configured git sources, local mount).
What this does NOT do
It does not fix a broken credential, and it must not try to. Rotating the GitHub App installation token an instance polls with is an operator action on a running system.