Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions Docs/ADR/025-structured-plugin-health-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./024-plugin-lifecycle-and-hosted-services.md) | [Next]()

# [ADR-025] Expose Structured And Cancellable Plugin Health Results

*2026-09* | Status: accepted

**Tag:** #adr_025

**Date:** 2026-09-13

**Scope:** AuthKit.Plugins.Abstractions + Host

## Context

Plugins may depend on several independent components, such as database, cache,
external API, or message queue. A single boolean health value cannot preserve
which component is degraded or why check failed. Health checks may also perform
asynchronous I/O and need to stop when the request or host is cancelled.

## Problem

The original plugin contract returned `Task<bool> CheckHealthAsync(IServiceProvider)`.
That shape loses intermediate health states, diagnostic information, multiple
observations, and cancellation. Replacing it without a migration path would
break existing plugin implementations.

## Decision

`IAuthKitPlugin.CheckHealthAsync` returns:

```csharp
Task<IReadOnlyList<PluginHealthResult>> CheckHealthAsync(
IServiceProvider services,
CancellationToken cancellationToken = default)
```

`PluginHealthResult` contains the strongly typed `PluginHealthStatus`, an optional
reason, and optional plugin owned diagnostic data. `Healthy`, `Degraded`, and
`Unhealthy` remain distinct. A plugin may return one result or multiple results,
with each result representing an independent observation.

The default interface implementation returns one `Healthy` result so plugins that
do not provide custom check remain valid. The host preserves the result list and
uses the supplied request cancellation token. Cancellation is propagated rather
than converted into fabricated health result.

## Rejected

- Keeping `bool` would discard degraded state and diagnostics.
- Collapsing multiple results inside the plugin would make host aggregation lossy.
- Inferring status from `Reason` or `Data` would make the contract weakly typed.
- Silently replacing cancellation with `Healthy` or `Unhealthy` would hide an
incomplete check.
- Adding second health method would leave two competing public contracts.

## Consequences

Health consumers must handle list of structured results and define aggregation
explicitly. The host health endpoint reports the highest severity status while
preserving every plugin result in the response. Plugin-specific diagnostic keys
remain extensible, but they do not override `Status`.

The change is source breaking for plugins that implement the old `Task<bool>`
method; shipped plugins and the manifest example are migrated together. The
contract validator invokes the new method and rejects an empty result collection.

## Related

- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin contract and dynamic loading
- [ADR-024](./024-plugin-lifecycle-and-hosted-services.md) - plugin lifecycle integration
- [Issue #14](https://github.com/AuthKits/AuthKit.Server/issues/14) - structured plugin health result
- [Issue #15](https://github.com/AuthKits/AuthKit.Server/issues/15) - multiple results and cancellation

[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./024-plugin-lifecycle-and-hosted-services.md) | [Next]()
19 changes: 15 additions & 4 deletions src/Host/Configuration/EndpointConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using AuthKit.Plugins.Abstractions.Models;
using Core.KeyManagement.Interfaces;
using Host.Plugins;

Expand Down Expand Up @@ -59,15 +60,25 @@ public static WebApplication MapAppEndpoints(
{
var keyStoreHealthy = keyStore.GetPublicJwks().Any();

var pluginResults = new Dictionary<string, bool>();
var pluginResults = new Dictionary<string, IReadOnlyList<PluginHealthResult>>();
foreach (var lp in plugins)
pluginResults[lp.Plugin.Name] = await lp.Plugin.CheckHealthAsync(context.RequestServices);
pluginResults[lp.Plugin.Name] = await lp.Plugin.CheckHealthAsync(
context.RequestServices,
context.RequestAborted);

var healthy = keyStoreHealthy && pluginResults.Values.All(ok => ok);
var pluginStatus = pluginResults.Values
.SelectMany(results => results)
.Select(result => result.Status)
.DefaultIfEmpty(PluginHealthStatus.Healthy)
.Max();
var status = keyStoreHealthy
? pluginStatus
: PluginHealthStatus.Unhealthy;
var healthy = status == PluginHealthStatus.Healthy;

return Results.Json(new
{
status = healthy ? "Healthy" : "Unhealthy",
status = status.ToString(),
time = DateTime.UtcNow,
jwtKeyStore = keyStoreHealthy ? "Healthy" : "Unhealthy",
plugins = pluginResults
Expand Down
19 changes: 13 additions & 6 deletions src/Host/KeyManagement/Security/JwtKeyStoreInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,19 @@ public async Task StartAsync(CancellationToken cancellationToken)

var stopwatch = Stopwatch.StartNew();

await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Initializing JWT KeyStore...",
async _ => await store.InitializeAsync());
if (System.Console.IsOutputRedirected)
{
await store.InitializeAsync();
}
else
{
await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Initializing JWT KeyStore...",
async _ => await store.InitializeAsync());
}

stopwatch.Stop();

Expand Down
25 changes: 14 additions & 11 deletions src/Plugins/Abstractions/Contracts/IAuthKitPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,12 @@ void ConfigureServices(IServiceCollection services, AuthKitPluginContext context
ConfigureServices(services, context.Configuration);

/// <summary>
/// Performs an optional health check for the plugin.
/// Performs an optional structured health check for the plugin.
/// </summary>
/// <param name="services">The root service provider of the host application.</param>
/// <param name="cancellationToken">A token that can cancel the health check.</param>
/// <returns>
/// <c>true</c> when the plugin is currently able to serve requests;
/// otherwise, <c>false</c>.
/// One or more structured health results reported by the plugin.
/// </returns>
/// <remarks>
/// <para>
Expand All @@ -221,18 +221,21 @@ void ConfigureServices(IServiceCollection services, AuthKitPluginContext context
/// dependencies.
/// </para>
/// <para>
/// A plugin should return <c>false</c> when a required dependency is
/// unavailable, such as when its database or external service cannot
/// currently be reached.
/// A plugin may return separate results for independent dependencies or
/// capabilities. Cancellation must be propagated to cancellable operations
/// and is not converted into a fabricated health result.
/// </para>
/// <para>
/// The default implementation reports the plugin as healthy. Plugins
/// that do not require custom health validation therefore do not need
/// to implement this member.
/// The default implementation reports the plugin as healthy. Existing
/// plugins that do not require custom health validation therefore do not
/// need to implement this member.
/// </para>
/// </remarks>
Task<bool> CheckHealthAsync(IServiceProvider services) =>
Task.FromResult(true);
Task<IReadOnlyList<PluginHealthResult>> CheckHealthAsync(
IServiceProvider services,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<PluginHealthResult>>(
[new PluginHealthResult(PluginHealthStatus.Healthy)]);

/// <summary>
/// Gets the optional ASP.NET Core middleware type contributed by the plugin.
Expand Down
58 changes: 58 additions & 0 deletions src/Plugins/Abstractions/Models/PluginHealthResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
namespace AuthKit.Plugins.Abstractions.Models;

/// <summary>
/// Represents structured health observation reported by an AuthKit plugin.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Status"/> provides the strongly typed health classification. The
/// optional <see cref="Reason"/> and <see cref="Data"/> members add context but
/// must not redefine or override that classification.
/// </para>
/// <para>
/// <see cref="Data"/> is owned by the plugin and may contain plugin-specific
/// diagnostic values such as dependency names, endpoint information, or queue
/// depth. The host may serialize this data without assigning it health semantics.
/// </para>
/// </remarks>
public sealed record PluginHealthResult
{
/// <summary>
/// Initializes a structured plugin health result.
/// </summary>
/// <param name="status">The strongly typed operational health state.</param>
/// <param name="reason">An optional human-readable explanation of the health state.</param>
/// <param name="data">Optional plugin-owned diagnostic data.</param>
public PluginHealthResult(
PluginHealthStatus status,
string? reason = null,
IReadOnlyDictionary<string, object>? data = null)
{
Status = status;
Reason = reason;
Data = data;
}

/// <summary>
/// Gets the strongly typed operational health state.
/// </summary>
public PluginHealthStatus Status { get; init; }

/// <summary>
/// Gets the optional human-readable explanation of the health state.
/// </summary>
/// <remarks>
/// A reason is not required when <see cref="Status"/> is
/// <see cref="PluginHealthStatus.Healthy"/>.
/// </remarks>
public string? Reason { get; init; }

/// <summary>
/// Gets optional plugin-owned diagnostic data.
/// </summary>
/// <remarks>
/// Diagnostic data is extensible plugin-specific information. It must not
/// be used to replace the strongly typed value of <see cref="Status"/>.
/// </remarks>
public IReadOnlyDictionary<string, object>? Data { get; init; }
}
33 changes: 33 additions & 0 deletions src/Plugins/Abstractions/Models/PluginHealthStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace AuthKit.Plugins.Abstractions.Models;

/// <summary>
/// Represents the operational health state reported by an AuthKit plugin.
/// </summary>
/// <remarks>
/// <para>
/// The status is the authoritative health classification consumed by the host.
/// It must not be inferred from the optional reason or diagnostic data.
/// </para>
/// <para>
/// <see cref="Degraded"/> indicates that the plugin remains operational while
/// one or more non-critical capabilities or dependencies are impaired.
/// </para>
/// </remarks>
public enum PluginHealthStatus
{
/// <summary>
/// The plugin is operating normally.
/// </summary>
Healthy = 0,

/// <summary>
/// The plugin remains operational, but one or more capabilities or
/// non-critical dependencies are degraded.
/// </summary>
Degraded = 1,

/// <summary>
/// The plugin cannot operate correctly, or a critical dependency has failed.
/// </summary>
Unhealthy = 2
}
20 changes: 15 additions & 5 deletions src/Plugins/Solutions/DevTokens/DevTokensPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using AuthKit.Plugins.Abstractions.Contracts;
using AuthKit.Plugins.Abstractions.Contracts.Plugins;
using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes;
using AuthKit.Plugins.Abstractions.Models;
using FluentValidation;
using DevTokens.Interfaces;
using DevTokens.Middleware;
Expand Down Expand Up @@ -74,20 +75,29 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config

public Type MiddlewareType => typeof(DeveloperTokenMiddleware);

public async Task<bool> CheckHealthAsync(IServiceProvider services)
public async Task<IReadOnlyList<PluginHealthResult>> CheckHealthAsync(
IServiceProvider services,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var store = services.GetService<IDocumentStore>();
if (store is null) return false;
if (store is null)
return [new(PluginHealthStatus.Unhealthy, "Document store is unavailable.")];

try
{
await using var session = store.LightweightSession();
await session.Query<DeveloperToken>().Take(1).ToListAsync();
return true;
await session.Query<DeveloperToken>().Take(1).ToListAsync(token: cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
return [new(PluginHealthStatus.Healthy, "Developer token store is available.")];
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return false;
return [new(PluginHealthStatus.Unhealthy, "Developer token store is unavailable.")];
}
}

Expand Down
16 changes: 12 additions & 4 deletions src/Plugins/Solutions/DevTools/DevToolsPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using AuthKit.Plugins.Abstractions;
using AuthKit.Plugins.Abstractions.Contracts;
using AuthKit.Plugins.Abstractions.Contracts.Plugins;
using AuthKit.Plugins.Abstractions.Models;
using DevTools.Catalog;
using DevTools.Middleware;
using DevTools.Runtime;
Expand Down Expand Up @@ -72,19 +73,26 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
/// </summary>
/// <param name="services">The root service provider of the host application.</param>
/// <returns><c>true</c> when the catalog is available; otherwise, <c>false</c>. </returns>
public Task<bool> CheckHealthAsync(IServiceProvider services)
public Task<IReadOnlyList<PluginHealthResult>> CheckHealthAsync(
IServiceProvider services,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var catalog = services.GetService<IGrpcServiceCatalog>();
if (catalog is null) return Task.FromResult(false);
if (catalog is null)
return Task.FromResult<IReadOnlyList<PluginHealthResult>>(
[new(PluginHealthStatus.Unhealthy, "gRPC service catalog is unavailable.")]);

try
{
_ = catalog.GetServices();
return Task.FromResult(true);
return Task.FromResult<IReadOnlyList<PluginHealthResult>>(
[new(PluginHealthStatus.Healthy, "gRPC service catalog is available.")]);
}
catch
{
return Task.FromResult(false);
return Task.FromResult<IReadOnlyList<PluginHealthResult>>(
[new(PluginHealthStatus.Unhealthy, "gRPC service catalog is unavailable.")]);
}
}
}
Loading
Loading