Skip to content

[Feature] Introduce Plugin Discovery, Loading and Isolation #30

Description

@rian-be

Summary

Extend AuthKit.Plugins.Abstractions with first class plugin discovery, loading, and isolation mechanism covering section G: Discovery, loading and isolation.

The contract introduces swappable plugin loader/discoverer plugin manifest, host version verification, AssemblyLoadContext isolation, dependency ordered loading, disable/cache/log support, and integration with PluginContractValidator. The host pipeline owns discovery cache, compatibility gate, ordering and logging IPluginLoader only loads what it is given in sorted order.

Goal

Provide complete, first class implementation of the "Discovery, loading and isolation" area in the plugin contract, so that plugins can be discovered, loaded, and isolated safely without dependency conflicts and with optional hot reload.

The implementation must allow the host to:

  • discover plugins via swappable IPluginDiscoverer without activating them,
  • read PluginManifest before load for pre activation decisions,
  • reject incompatible plugins via MinHostVersion before load,
  • isolate each plugin in its own AssemblyLoadContext,
  • validate every loaded plugin automatically,
  • order plugins deterministically by DependsOn + Priority,
  • skip disabled plugins via EffectiveIsEnabled,
  • avoid full rediscovery via IPluginDiscoveryCache,
  • and observe every outcome via structured logging.

Problem

There is no discovery/isolation mechanism for plugins (AssemblyLoadContext), causing dependency conflicts between plugins and no hot reload. Discovery is implicit, metadata lives only on IAuthKitPlugin after construction, and version/capability/disable checks happen too late (at runtime). Without it, host runtime behavior is unpredictable when multiple plugins ship conflicting transitive dependencies.

Scope

Implementation is divided into the following sub issues:


Specification

ID Element Signature / Behavior Files Acceptance
G1 IPluginDiscoverer / IPluginLoader IAsyncEnumerable<DiscoveredPlugin> DiscoverAsync() reads manifest without activating; Task<IReadOnlyList<LoadedPlugin>> LoadAsync(IReadOnlyList<DiscoveredPlugin>) constructs without activating IPluginDiscoverer.cs, IPluginLoader.cs, DiscoveredPlugin.cs, LoadedPlugin.cs swappable discovery MUST NOT activate, loader receives sorted accepted list only
G2 Plugin manifest PluginManifest record mirroring A1–A13, read from plugin.json during discovery before load PluginManifest.cs consistent with IAuthKitPlugin instance (hard failure on mismatch) duplicate Id rejected
G3 Compatibility gate MinHostVersion HostVersion < MinHostVersion (SemVer 2.0.0) -> reject, no warn-only host pipeline prerelease and build metadata semantics correct
G4 AssemblyLoadContext isolation one collectible AssemblyLoadContext per plugin, attached to LoadedPlugin loader no type collisions; host assemblies shared
G5 PluginContractValidator integration runs automatically after isolation, before activation fail -> startup error tools/PluginContractValidator, host pipeline non-conforming -> hard reject
G6 Extend validator with new hooks validates G1/G2/B/C/D/F presence/signatures tools/PluginContractValidator full contract coverage, reports member name
G7 Resolving DependsOn topological sort ready set by Priority asc -> RegistrationOrder asc host pipeline no cycles/self/duplicate/missing; deps first deterministic
G8 Disable via configuration EffectiveIsEnabled(manifest, config) checked at gate before ordering/loading host pipeline disabled -> not ordered/isolated/validated/loaded
G9 Cache discovered plugins IPluginDiscoveryCache before discoverer; stores only discovery results + fingerprints IPluginDiscoveryCache.cs (or host internal) hit avoids rediscovery never stores ALC/runtime objects
G10 Log discovered plugins host pipeline logs discovery and final outcome with reason host pipeline Information/Warning/Error per outcome

G1. Discoverer / Loader

Separate discovery (finding candidates and reading manifest) from loading (constructing IAuthKitPlugin instance). Loader receives exactly what the pipeline produced it does not rediscover and does not activate.

Requirements

  • Discoverer returns async stream of DiscoveredPlugin without activating any plugin it reads manifest during discovery.
  • Loader receives IReadOnlyList<DiscoveredPlugin> (already validated, gated, sorted) and returns IReadOnlyList<LoadedPlugin> it does not rediscover.
  • LoadAsync loads and constructs but does not activate runtime behavior.
  • Implementation is swappable via host pipeline.
  • Location is discovery source specific (directory/dll/zip/uri) and not interpreted by contract.

G2. Plugin Manifest

PluginManifest mirrors A1–A13 for pre activation decisions. Read by discoverer before assembly load.

Requirements

  • Id, Name, Version required; MinHostVersion, DependsOn optional.
  • SemVer 2.0.0 build metadata ignored for precedence; never System.Version.
  • PluginManifest does not inherit from IAuthKitPlugin.
  • Post load consistency: Id/Name/Version/IsEnabled/set-equal Capabilities (OrdinalIgnoreCase) must match instance; mismatch -> hard REJECT.
  • Duplicate Id across discovered manifests -> deterministic reject before activation.

G3. Compatibility Gate MinHostVersion

Gate (not loader) compares manifest.MinHostVersion to host version and rejects before loading.

if (manifest.MinHostVersion is not null && hostVersion < manifest.MinHostVersion)
    reject;

Requirements

  • Host 2.3.9 with Min 2.4.0 -> reject; 2.4.0 -> accept; 2.4.1/3.0.0 -> accept.
  • Prerelease: 1.2.3-alpha < 1.2.3-alpha.1 < 1.2.3-beta < 1.2.3.
  • Build metadata does not affect precedence.
  • No MinHostVersion -> unaffected no warnonly mode.

G4. AssemblyLoadContext Isolation

Loader creates one AssemblyLoadContext per plugin (preferably IsCollectible). Host/framework assemblies shared only plugin private dependencies isolated. Context attached to LoadedPlugin, not DiscoveredPlugin.

Requirements

  • Each plugin loads into its own ALC no type collisions between conflicting dependency versions.
  • Host types shared, not duplicated.
  • IsCollectible where possible.

G5. Validator Integration

Loader pipeline invokes PluginContractValidator on each loaded plugin/type after isolation and before activation. Failure -> startup error (hard reject, Strict/Warn/Ignore is later host policy).


G6. Validator Coverage

Add rules for every new contract member: G1 loader/discoverer interfaces, G2 manifest shape, B lifecycle hooks, C middleware models, D health result, F security scheme extensions. Each rule reports specific missing/invalid member.


G7. Resolving DependsOn

Host pipeline builds dependency graph from DependsOn (plugin Id values) and topologically sorts it before invoking loader.

Algorithm

Kahn + priority queue: at each step among dependency ready set pick lowest Priority asc then RegistrationOrder asc. Never resort entire result.

Example: A p-100 -> B p-200 DependsOn A, C p0. Ready {A,C} -> pick A (-100), ready {B,C} -> pick B (-200) -> C ⇒ A, B, C. With A p100, C p0 the correct ascending result is C, A, B.

Requirements

  • Cycle -> startup error deps before dependents deterministic.
  • DependsOn entries are Id values missing/self/duplicate/invalid Id -> reject, cycle -> startup error.
  • Graph validation failure -> startup failure (not per plugin reject).
  • Dependency unavailable (disabled/incompatible/validation/load failed) -> dependent REJECTED with dependency unavailable.
  • No version range resolution.

G8. Disable via Configuration

Compatibility gate checks EffectiveIsEnabled(manifest, config) before ordering/loading.

manifest true  + absent -> true
manifest true  + true   -> true
manifest true  + false  -> false
manifest false + *      -> false

Host may disable true -> false, never enable false -> true. Does not mutate PluginManifest consistency check stays manifest.IsEnabled == plugin.IsEnabled.

Disabled plugin is not ordered (G7), not isolated (G4), not validated (G5), not loaded. Composes with IsMiddlewareEnabled (C).


G9. Cache Discovered Plugins

Host owned IPluginDiscoveryCache sits before discovery:

public interface IPluginDiscoveryCache
{
    Task<IReadOnlyList<DiscoveredPlugin>?> TryGetAsync(CancellationToken ct = default);
    Task StoreAsync(IReadOnlyList<DiscoveredPlugin> plugins, CancellationToken ct = default);
}
// internal: PluginDiscoveryCacheEntry { Location, SourceFingerprint, SchemaVersion, PluginManifest }

Hit reuses DiscoveredPlugin[] without calling discoverer miss calls discoverer then StoreAsync. Stores only discovery results + fingerprints, never LoadedPlugin/IAuthKitPlugin/Type/ALC/process bound state. Cache key is assembly paths/hashes. Host local, no distributed cache.


G10. Log Discovered Plugins

Host pipeline logs at two moments:

  • Discovery: Information discovered id version
  • Final outcome: Information loaded / Warning skipped reason=disabled / Error rejected reason=minHostVersion|dependency-unavailable|mismatch|validation

Reasons from G3/G5/G7/G8 included. No structured telemetry here (Section H).


Architecture

flowchart TD
    Cache["Discovery Cache G9<br/>IPluginDiscoveryCache"] --> Check{"Cache valid?"}
    Check -- yes --> Reuse["HIT → DiscoveredPlugin[]"]
    Check -- no --> Disc["IPluginDiscoverer G1<br/>plugin.json → Manifest"]
    Disc --> Store["StoreAsync<br/>Manifest + Location<br/>+ fingerprint"]
    Store --> Merge
    Reuse --> Merge["DiscoveredPlugin[]"]
    Merge --> PreVal["Pre-load validation G2<br/>structural + duplicate Id"]
    PreVal -- invalid → startup error --> Err0["startup failure"]
    PreVal -- ok --> Gate{"Gate G3/G8<br/>EffectiveIsEnabled<br/>MinHostVersion"}
    Gate -- disabled → SKIP --> L1["LOG Warning"]
    Gate -- incompatible → REJECT --> L2["LOG Error"]
    Gate -- accepted --> Graph["Dependency Graph G7"]
    Graph -- invalid → startup error --> Err1["startup failure"]
    Graph -- valid --> Sort["Topo sort<br/>Priority asc → RegistrationOrder"]
    Sort -- dependency unavailable --> DepRej["REJECT dependency-unavailable"]
    Sort -- ok --> Loader["Loader G1/G4<br/>sorted accepted list"]
    Loader --> Loaded["LoadedPlugin[]"]
    Loaded --> Val["Validator G5/G6<br/>+ manifest consistency"]
    Val --> Act["ACTIVATION"]
    Act --> Log["LOG G10<br/>discovery + outcome"]
    classDef gate fill:#fef3c7,stroke:#f59e0b,color:#92400e
    classDef err fill:#fee2e2,stroke:#ef4444,color:#991b1b
    classDef ok fill:#dcfce7,stroke:#22c55e,color:#14532d
    classDef cache fill:#dbeafe,stroke:#3b82f6,color:#1e40af
    class Cache,Check,Store,Reuse cache
    class PreVal,Gate,Graph,Sort gate
    class Err0,Err1,DepRej,L1,L2 err
    class Act ok
Loading
Component Responsibility
IPluginDiscoverer find + read manifests
IPluginDiscoveryCache cache discovery results (before discoverer)
Host pipeline gate + graph + ordering
IPluginLoader load + construct (sorted list)
Validator validate loaded plugin
Activation start plugin

Backward Compatibility

  • Mostly additive: existing plugins without manifest or load context continue via legacy fallback. Version string -> SemanticVersion (A2) is breaking requiring explicit migration.
  • New loader is opt in host may keep current IAuthKitPlugin registration.
  • MinHostVersion, IsEnabled, Capabilities, DependsOn read from PluginManifest before loading without manifest unaffected.
  • No removal of IAuthKitPlugin or AuthKitSecuritySchemeDescriptor.

Validation

  • dotnet build AuthKit.Plugins.Abstractions (0 errors).
  • tools/PluginContractValidator on DevTokens -> [PASS].
  • Tests covering each subtask:
    • G1 discoverer without activation, loader receives sorted list
    • G2 manifest read before load, duplicate Id and consistency rejection
    • G3 MinHostVersion prerelease/build metadata semantics
    • G4 ALC isolation, no type collisions, host shared
    • G5 validator runs automatically, hard reject on fail
    • G6 full coverage, reports member name
    • G7 cycle/self/duplicate/missing, topo + Priority ordering, dependency-unavailable propagation
    • G8 EffectiveIsEnabled precedence, not ordered/isolated when disabled
    • G9 hit avoids rediscovery, invalidation on hash change, never stores ALC
    • G10 discovery and outcome logs with reason
  • DevTokens still loads.

Acceptance Criteria

  • Each subtask in Scope meets its own criteria (Acceptance column).
  • IPluginDiscoverer/IPluginLoader swappable discovery does not activate.
  • PluginManifest consistent with instance (mismatch -> reject) duplicate Id rejected.
  • HostVersion < MinHostVersion -> reject (SemVer 2.0.0, no warn only).
  • Each plugin in own AssemblyLoadContext (collectible), host shared.
  • Validator runs automatically after isolation, before activation; covers all new hooks.
  • Dependencies ordered deterministically (Kahn + Priority tiebreak) invalid graph -> startup error unavailable dependency -> dependent rejected.
  • Disabled via EffectiveIsEnabled (host can disable, never enable).
  • Cache before discovery, stores only fingerprints, never runtime objects.
  • Every plugin logged at discovery and final outcome with reason.
  • Contract compiles and passes validator.

Non Goals

  • No change to host runtime behavior beyond new contract.
  • No breaking changes without migration (except documented A2).
  • No custom DI/configuration engine.
  • No max host version ceiling.
  • No version range resolution between dependents.
  • No runtime enable/disable toggle.
  • No distributed cache or cached activated instances.
  • No structured telemetry/metrics (Section H).
Pinned by rian-be

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P1Core operationadditiveAdditive, non-breaking changearea/abstractionsAuthKit.Plugins.Abstractions contractcontractChanges the plugin contractepicParent/umbrella issue

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions