Skip to content

[Task] Introduce Plugin Discovery, Manifest and Compatibility Gate #27

Description

@rian-be

Summary

Introduce the discovery and loading foundation for plugins: swappable discovery/loader interfaces, declarative plugin manifest carrying the A1–A13 metadata, and host compatibility gate whose first rule is MinHostVersion with clear separation between discovery, compatibility decisions, loading (construct instance), and activation (start runtime behavior).

Goal

Let the host discover and load plugins through pluggable mechanism, read plugin metadata before activation, and reject incompatible plugins with the compatibility gate running before loading and the full contract validation running after the isolated load.

Background

Plugins are currently registered implicitly by the host, with no abstraction that lets the host choose how and from where plugins are discovered. Metadata lives only on the IAuthKitPlugin instance, so the host cannot reason about plugin until it is constructed, and a plugin requiring newer host features fails only at runtime. These gaps block the rest of Section G (isolation, ordering, validation).

Architecture

The responsibilities are deliberately separated:

flowchart TD
    D1["IPluginDiscoverer<br/>reads plugin.json → PluginManifest"] --> D2["DiscoveredPlugin<br/>Manifest + Location"]
    D2 --> Gate{"Compatibility Gate"}
    Gate -- accepted --> Loader["IPluginLoader<br/>constructs instance"]
    Gate -- rejected --> Rej["REJECT / SKIP<br/>disabled G8<br/>invalid manifest<br/>duplicate Id<br/>incompatible host G3"]
    Loader --> Loaded["LoadedPlugin<br/>Manifest + Instance"]
    Loaded --> V1["Plugin validation<br/>G5/G6"]
    V1 --> V2["Manifest ↔ Plugin<br/>consistency"]
    V2 --> Dep["Dependency ordering<br/>G7"]
    Dep --> Act["ACTIVATION"]
    classDef gate fill:#fef3c7,stroke:#f59e0b,color:#92400e
    classDef reject fill:#fee2e2,stroke:#ef4444,color:#991b1b
    classDef ok fill:#dcfce7,stroke:#22c55e,color:#14532d
    class Gate gate
    class Rej reject
    class Act ok
Loading

Target shape for G1–G3:

flowchart TD
    Disc["IPluginDiscoverer"] -- "reads plugin.json" --> DP["DiscoveredPlugin"]
    DP --> M1["Manifest"]
    DP --> Loc["Location"]
    DP --> Gate2{"Compatibility Gate"}
    Gate2 -- disabled --> SK2["SKIP"]
    Gate2 -- incompatible --> RJ2["REJECT"]
    Gate2 -- accepted --> Acc["accepted plugin"]
    Acc --> Loader2["IPluginLoader"]
    Loader2 --> LP["LoadedPlugin<br/>Manifest + Instance"]
    LP --> Act2["ACTIVATION"]
    classDef gate fill:#fef3c7,stroke:#f59e0b,color:#92400e
    classDef reject fill:#fee2e2,stroke:#ef4444,color:#991b1b
    classDef ok fill:#dcfce7,stroke:#22c55e,color:#14532d
    class Gate2 gate
    class SK2,RJ2 reject
    class Act2 ok
Loading

Scope

G1. IPluginLoader / IPluginDiscoverer

Separate discovery (finding candidate plugins and reading their manifest) from loading (constructing the IAuthKitPlugin instance). The loader receives exactly what the discoverer produced it does not rediscover and it does not activate runtime behavior.

G2. Plugin manifest

Add PluginManifest record that mirrors the metadata surface from A1–A13. The discoverer reads it from plugin.json / plugin.manifest during discovery, before the plugin assembly is loaded or activated, so the compatibility gate can run first.

G3. Compatibility gate MinHostVersion

The compatibility gate (not the loader) compares manifest.MinHostVersion (semver) to the host's reported version and rejects mismatches before loading. MinHostVersion is the first rule of gate that may later also cover enabled, required host capabilities, platform, and architecture but only MinHostVersion is implemented now.

Proposed Contract

public interface IPluginDiscoverer
{
    IAsyncEnumerable<DiscoveredPlugin> DiscoverAsync(CancellationToken ct = default);
}

public sealed record DiscoveredPlugin
{
    public required PluginManifest Manifest { get; init; }
    // Location identifies the source from which the loader can load the plugin.
    // Its format is discovery source specific (directory, dll, zip, package, uri, ...)
    // and must not be interpreted by the contract.
    public required string Location { get; init; }
}

public interface IPluginLoader
{
    Task<IReadOnlyList<LoadedPlugin>> LoadAsync(
        IReadOnlyList<DiscoveredPlugin> plugins,
        CancellationToken ct = default);
}

public sealed record LoadedPlugin
{
    public required PluginManifest Manifest { get; init; }
    public required Type PluginType { get; init; }
    public required IAuthKitPlugin Instance { get; init; }
}

public sealed record PluginManifest
{
    public required string Id { get; init; }
    public required string Name { get; init; }
    public required SemanticVersion Version { get; init; }
    public SemanticVersion? MinHostVersion { get; init; }
    public IReadOnlyList<string> DependsOn { get; init; } = Array.Empty<string>();

    // A6–A8/A11 — classification/lifecycle/feature metadata, available pre activation
    public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
    public int Priority { get; init; }
    public bool IsEnabled { get; init; } = true;
    public IReadOnlySet<string> Capabilities { get; init; }
        = ImmutableHashSet.Create<string>(StringComparer.OrdinalIgnoreCase);

    // remaining metadata mirrors A1–A13
}

The host owns the default discoverer/loader implementation plugins never implement them. IPluginLoader loads and constructs the plugin instance (returns LoadedPlugin) but does not start/activate its runtime behavior activation is the host's separate step after validation, consistency, and ordering.

Compatibility check (host pipeline, not inside the loader):

if (manifest.MinHostVersion is not null && hostVersion < manifest.MinHostVersion)
{
    reject; // hard failure, no "warn only" at this stage
}

Requirements

G1. Loader interface

  • The discoverer returns an async stream of DiscoveredPlugin without activating any plugin; it reads the manifest during discovery.
  • The loader receives the discovered plugins (IReadOnlyList<DiscoveredPlugin>) and returns IReadOnlyList<LoadedPlugin> it does not re discover.
  • LoadAsync loads and constructs the instance but does not activate runtime behavior; activation is separate host step.
  • The loader implementation is swappable: the host can supply custom IPluginLoader/IPluginDiscoverer.
  • Discovery MUST NOT activate plugins. All activation/compatibility decisions (enabled G8, version gate G3, validation G5) happen in the host pipeline around the loader, never inside discovery or the loader.

G2. Manifest

  • Id, Name, Version are required MinHostVersion and DependsOn are optional.
  • The manifest is read by the discoverer during discovery, before the plugin assembly is loaded or activated.
  • Semantic versions follow SemVer 2.0.0 (e.g. 1.2.3, 1.2.3-alpha, 1.2.3-beta.1, 1.2.3+build). Comparison must use SemVer-2.0.0-aware type, never System.Version.
  • Build metadata does not affect precedence: 1.0.0+abc and 1.0.0+xyz have the same precedence.
  • The manifest declares pre activation metadata IAuthKitPlugin is the runtime contract. They share semantics (Id/Name/Version/DependsOn/IsEnabled/Capabilities/...) but are not the same model and must not be coupled via PluginManifest : IAuthKitPlugin.
  • The loaded manifest must be consistent with the IAuthKitPlugin instance (same Id, Name, Version, IsEnabled, and set-equal Capabilities with OrdinalIgnoreCase). A mismatch is hard validation failure -> REJECT.
  • IsEnabled and Capabilities (A6–A8/A11) are projected into the manifest so the compatibility gate can decide before loading the post load consistency check re verifies them against the instance.
  • Duplicate Ids: multiple discovered manifests declaring the same Id must be rejected deterministically before activation (required for the dependency graph in G7).

G3. Compatibility gate (MinHostVersion)

  • Reject rule: HostVersion < MinHostVersion -> reject.
  • Examples for MinHostVersion = 2.4.0:
    • Host 2.3.9 -> reject
    • Host 2.4.0 -> accept
    • Host 2.4.1 -> accept
    • Host 3.0.0 -> accept
  • Prerelease ordering must hold: 1.2.3-alpha < 1.2.3 and 1.2.3-alpha < 1.2.3-alpha.1 < 1.2.3-beta.
  • A plugin without MinHostVersion is unaffected.
  • No "warn only" mode at this stage. Loading plugin that requires newer host risks MissingMethodException / TypeLoadException / behavior corruption, so the gate is hard reject. A Strict / Warn / Ignore policy engine may be added later as a separate host feature, but it is not part of the basic compatibility gate.
  • MinHostVersion is one rule of the compatibility gate. The gate also consumes manifest.IsEnabled (A8/G8) and manifest.Capabilities (A11) before loading; host capability checks use the manifest's capability set pre activation, and the manifest/instance capability sets are re verified for consistency after load.

Backward Compatibility

Two discovery paths coexist:

  • Manifest-based plugin pre-activation metadata is available from PluginManifest enables G3/G8/G7 before loading.
  • Legacy plugin (no manifest) — metadata is available only after activation (read from IAuthKitPlugin). It falls back to the existing implicit loading path; G3/G8 cannot run for it because the metadata is not known beforehand.

Plugins that do not declare MinHostVersion are unaffected. The new loader is an opt in discovery path layered on top of the current registration.

Loading Pipeline

Realistic host pipeline order (cross references other Section G items):

flowchart TD
    A["DISCOVERY<br/>reads PluginManifest"] --> B{"Pre-load validation<br/>structural + duplicate Id?"}
    B -- REJECT --> R["REJECT"]
    B -- ok --> C{"Compatibility Gate"}
    C -- "IsEnabled==false → SKIP" --> S["SKIP"]
    C -- "Host < MinHostVersion → REJECT" --> R
    C -- ok --> D["ISOLATED LOAD → LoadedPlugin<br/>G4"]
    D --> E["PluginContractValidator<br/>G5/G6"]
    E --> F["Manifest ↔ IAuthKitPlugin consistency<br/>Id/Name/Version/IsEnabled/Capabilities"]
    F --> G["DEPENDENCY GRAPH G7<br/>Priority asc → RegistrationOrder asc"]
    G --> H["ACTIVATION"]
    classDef gate fill:#fef3c7,stroke:#f59e0b,color:#92400e
    classDef reject fill:#fee2e2,stroke:#ef4444,color:#991b1b
    classDef ok fill:#dcfce7,stroke:#22c55e,color:#14532d
    class B,C gate
    class R,S reject
    class H ok
Loading

Pre load manifest validation (structural only, before the assembly is loaded):

  • Id / Name not empty
  • Version is valid SemVer
  • MinHostVersion is valid SemVer
  • DependsOn entries valid
  • no duplicate Id
  • manifest schema valid

Post load plugin validation (after the isolated load, may need the loaded type/assembly):

  • IAuthKitPlugin implementation
  • Middleware (C)
  • Security schemes (F)
  • Health checks (D)
  • etc. (full PluginContractValidator, G5/G6)

Validation

G1. Loader interface

  • IPluginDiscoverer and IPluginLoader interfaces exist.
  • The discoverer returns DiscoveredPlugin (with manifest read during discovery) without activating plugins.
  • IPluginLoader.LoadAsync accepts discovered plugins and returns IReadOnlyList<LoadedPlugin>.
  • LoadAsync constructs the instance but does not activate runtime behavior.
  • A custom loader/discoverer can replace the default.
  • DevTokens still loads after the contract change.
  • PluginContractValidator passes.

G2. Manifest

  • PluginManifest record exists with the A1–A13 fields, including Tags/Priority/IsEnabled/Capabilities (A6–A8/A11) for pre activation.
  • The discoverer reads the manifest before activation.
  • A manifest/plugin Id/Name/Version mismatch is hard rejection.
  • A manifest/instance IsEnabled mismatch or non set equal Capabilities is hard rejection (consistency).
  • Duplicate Id across discovered plugins is rejected before activation.
  • Semantic version comparison uses SemVer 2.0.0 semantics; build metadata ignored for precedence.
  • PluginManifest does not inherit from IAuthKitPlugin.

G3. Compatibility gate

  • Reject when HostVersion < MinHostVersion (with the 2.4.0 examples above).
  • Prerelease ordering: 1.2.3-alpha < 1.2.3; 1.2.3-alpha < 1.2.3-alpha.1 < 1.2.3-beta.
  • A plugin without MinHostVersion is unaffected.
  • No "warn only" fallback at this stage.

Legacy

  • A plugin without manifest loads via the legacy path (metadata after activation).
  • A manifest based plugin is rejected on manifest <-> instance inconsistency.

Acceptance Criteria

  • The loader implementation is swappable: the host can supply custom IPluginLoader/IPluginDiscoverer.
  • The discoverer returns DiscoveredPlugin (manifest read during discovery) without activating plugins discovery MUST NOT activate plugins.
  • IPluginLoader.LoadAsync receives the discovered plugins and returns LoadedPlugin instances (constructed, not activated).
  • Metadata from A1–A13 loads from file before activation.
  • The loaded manifest is consistent (hard failure on mismatch: Id/Name/Version/IsEnabled/set equal Capabilities) with the IAuthKitPlugin instance the plugin ultimately provides.
  • Reject rule: HostVersion < MinHostVersion.
  • Duplicate Id across discovered manifests is rejected before activation.
  • A plugin without MinHostVersion is unaffected.
  • Legacy plugins without manifest still load via the fallback path.

Non Goals

  • No "warn only" policy in the basic compatibility gate (a separate host policy engine may add Strict/Warn/Ignore later).
  • DiscoveredPlugin does not carry AssemblyLoadContext that is added by G4 via LoadedPlugin.
  • No PluginManifest : IAuthKitPlugin inheritance the two share semantics but are separate models.
  • The loader does not decide compatibility (enabled/version/validation) those live in the host pipeline around it.
  • IPluginLoader.LoadAsync does not return PluginLoadResult, PluginLoadResult is an internal host concept (see below).
  • No specific discovery source (filesystem, package feed, database) is mandated; Location format is discovery source specific.
  • No new metadata schema beyond A1–A13 is introduced.
  • No maximum host version ceiling is enforced here (can be added later).
  • No per feature capability negotiation beyond the version check (left as future compatibility gate rule).

PluginLoadResult (host pipeline, not contract)

IPluginLoader returns only the accepted LoadedPlugin list, but the host pipeline should track outcomes for diagnostics:

Loaded:    DevTokens
Rejected:  Foo -> MinHostVersion 3.0 > Host 2.4
Disabled:  Bar
Invalid:   Baz  -> manifest/plugin mismatch
Duplicate: Qux -> Id "devtokens" already discovered

This PluginLoadResult is an internal host concept and is intentionally not part of IAuthKitPlugin or the base loader contract. It is useful for startup logs, diagnostics, health, admin endpoints, and telemetry.

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 contractsub-taskChild task of an epic

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions