checkId: check.core.plugins.loaded plugin: stellaops.doctor.core severity: fail tags: [quick, core, plugins]
Mounted plugins admitted
Doctor check
check.core.plugins.loaded— closes the “rejected plugin → its checks disappear from the run → Doctor still reports all-green” hole (ADR-026 Decision 6). It compares the expected set of mounted Doctor runtime plugins against the set actually admitted, and reports any plugin that was rejected (whose checks would otherwise silently vanish) with the rejection reason.
What It Checks
This is a core check: it is contributed by the in-process Core plugin, so it always runs independent of mounted-plugin admission — the very property that lets it report a rejected mounted plugin, because the rejected plugin’s own checks have already disappeared from the run.
It reads the mounted-plugin admission state through the engine-lib seam IMountedPluginAdmissionStatus, which the Doctor WebService backs with the same mounted-bundle loader catalog that /readyz and the GET /internal/plugins/status probe report read. So a Doctor RUN and /readyz agree about a rejected/vanished plugin — there is no green run that hides one.
For each expected mounted plugin (a target the loader iterates — LoadFromMountedBundle:true in DoctorRuntimePluginTargets.MountedLoaderTargets):
| Outcome | Condition |
|---|---|
| Admitted | the bundle cleared manifest/checksum/path-traversal/signature admission and registered |
| Rejected | admission failed (missing/invalid trust-root, signature mismatch, unresolved transitive dependency, bundle not mounted) or the loader never recorded an admission status for the expected target |
| Result | Condition |
|---|---|
| Pass | every expected mounted plugin was admitted |
| Fail | one or more expected mounted plugins were rejected (each named with its reason in the evidence) |
| Skip | there is no mounted-plugin host (the seam is not registered — e.g. the CLI). It does not Pass-by-default: “no host to evaluate” is not “all plugins admitted” |
The evidence emits ExpectedCount, AdmittedCount, RejectedCount, and one rejected:<plugin-id> line per rejected plugin carrying the same reason the probe reporter / /readyz surface.
Why It Matters
Mounted Doctor runtime plugins (Release, Environment, Scanner, Compliance, BinaryAnalysis, Timestamping) load from signed bundles. If a bundle is rejected — most commonly because the trust-root (cosign.pub) is missing, the signature does not verify, or a transitive dependency is unresolved — the loader silently rejects the whole plugin and every check it contributes simply disappears from the run. Without this check, Doctor would then report all-green while entire families of checks were never executed: a green light that proves nothing. This check turns that silent disappearance into a visible Fail that names the rejected plugin and why.
Common Causes
- The plugin trust-root (
cosign.pub) is missing or unreadable, so signed bundles fail signature admission (seecheck.core.plugins.trustroot). - The bundle’s detached signature does not verify against the trust-root (tampered or re-built bundle without re-signing).
- A transitive dependency of the plugin assembly is missing (the loader loads via
Assembly.LoadFrom+GetTypes(); an unresolved dependency rejects the whole plugin). - The expected bundle is not mounted at the configured profile root (
Doctor:RuntimePlugins:RootPath/Profile), so the loader records “not mounted”.
How to Fix
For each rejected plugin in the evidence, read the reason, then probe the loader directly and restore the bundle/trust-root or fix the signature/dependency, then redeploy the Doctor service so admission succeeds.
Inspect the loader:
curl -s http://doctor-web:10261/internal/plugins/status | jq '.plugins[] | select(.admitted == false)'
curl -s -X POST http://doctor-web:10261/internal/plugins/probe
Docker Compose
The mounted bundles and trust-root are read-only mounts in devops/compose/docker-compose.plugins.doctor.yml. Confirm the bundle directory and cosign.pub are present, then recreate the service:
# Verify the mounts exist on the host, then:
docker compose -f devops/compose/docker-compose.plugins.doctor.yml up -d --force-recreate doctor-web
If a bundle was rebuilt, re-produce and re-sign it:
pwsh devops/build/package-runtime-plugins.ps1 -Module doctor -Profile base -SignDoctorBundles -UseOfflineDevSigner
Both switches are required. -SignDoctorBundles enables signing; -UseOfflineDevSigner selects the local RSA/SHA-256 signer. Without the first switch, the packager emits unsigned bundles and the fail-closed loader reports the missing <assembly>.dll.sig.
Bare Metal / systemd
Ensure the bundle directory (default /app/plugins/doctor/base/<plugin-id>) and the trust-root (default /app/etc/certificates/trust-roots/plugins/doctor/cosign.pub) exist and are readable by the service user, then restart:
sudo systemctl restart stellaops-doctor
Kubernetes / Helm
The bundles and trust-root are typically mounted from a ConfigMap/Secret. Confirm they are present and correctly keyed, then roll out:
kubectl rollout restart deployment/doctor
Verification
stella doctor run --check check.core.plugins.loaded
A Pass means every expected mounted plugin was admitted. A Fail lists each rejected plugin (rejected:<id> lines) with the reason; a Skip means this host has no mounted plugins to evaluate.
Related Checks
check.core.plugins.trustroot— reports a missing/invalid plugin trust-root, the most common upstream cause of a silent plugin rejection.check.core.integration-coverage— the complementary “an integration has no owning check” hole; together they ensure neither a missing check nor a vanished plugin hides behind a green run.
See the Doctor reference for the full check catalog, CLI usage, and export bundles.
