Snapshot for branch feature-plugins (locally 2 commits ahead of origin/main,
including 988e202 — First iteration plugins).
This document covers only the new third-party plugin module track
(IAdaptiveApiPlugin); the legacy in-tree IWebPlugin extension point is left
alone.
1.1 Public SDK — src/AdaptiveApi.Plugins.SDK/
| File | Status |
|---|---|
| IAdaptiveApiPlugin.cs | Plugin entry point: Manifest, RegisterServices, MapRoutes. |
| PluginManifest.cs | Id / Name / Version / Description / Category / HasSettings / HasEndpoints / Dependencies. |
| IPluginContext.cs | Defined but never wired — no implementation, never injected. |
| IPluginSettingsStore.cs | Raw + typed get/set, keyed by (pluginId, tenantId). v1: tenantId = null only. |
| Hooks/IRequestTranslationHook.cs, IAiCallHook.cs, IResponseTranslationHook.cs | Three hook interfaces. |
| Hooks/HookResult.cs | Continue / Modify(body) / ShortCircuit(status, body, ct). |
| Hooks/PipelineHookContext.cs | Per-request bag with HttpContext, RouteId, TenantId, ProviderId, languages, direction, free-form Properties. |
| AdaptiveApi.Plugins.SDK.csproj | Bare project — no PackageId, no TFM pinning, no version, not packed for NuGet. |
1.2 Host wiring — src/AdaptiveApi.Core/Plugins/ and src/AdaptiveApi.Api/
- Dispatcher: PluginHookDispatcher.cs fans hook calls. Translation hooks fail-open; AI hooks fail-closed (500 on throw). Composition: DI order, body threaded forward, first short-circuit wins.
- Registry: IPluginRegistry.cs —
manifest snapshot built at startup from
IAdaptiveApiPluginsingletons. - Loader: PluginLoader.cs —
scans current AppDomain, then
AdaptiveApi.*.dllnext to the host, then aplugins/subfolder. Loads viaAssembly.LoadFrominto the default ALC. - Pipeline integration: hooks are called at six points in OpenAiChatAdapter, AnthropicMessagesAdapter, GenericAdapter, and McpRouteAdapter.
- Startup: Program.cs:83-97 calls
RegisterServicesper discovered module, then registers dispatcher / registry / settings store. Program.cs:154-158 maps/plugins/{id}route groups (no auth applied at the host level).
1.3 Persistence — src/AdaptiveApi.Infrastructure/
- Entity PluginSettingsEntity
with composite key
(TenantId, PluginId); mapping at LlmTransDbContext.cs:245-248. - Implementation DbPluginSettingsStore.cs.
Validates JSON syntax on write (via
JsonDocument.Parse) — does not validate against any plugin-supplied schema.
1.4 Admin REST — src/AdaptiveApi.Api/Admin/PluginEndpoints.cs
GET /admin/pluginsGET /admin/plugins/{id}/settingsPUT /admin/plugins/{id}/settings
Mounted inside the admin-policy group at
Program.cs:146.
| Surface | Status |
|---|---|
| Tests for the plugin system | None — no plugin test files in tests/AdaptiveApi.Core.Tests/ or tests/AdaptiveApi.Integration.Tests/. |
| Admin UI | No Plugins.vue in src/AdaptiveApi.Ui/src/pages/; plugin list and per-plugin settings editor not in the UI. |
| Example plugin | Nothing in examples/; nothing under tests/Fixtures/. No skeleton repo. |
| Docs | docs/docs/deployment.html:160-167 only documents the legacy IWebPlugin. No "Building a Plugin" page, no hook reference, no settings-schema guide. |
| Deploy | plugins/ directory is not surfaced as a volume mount in deploy/docker/ / deploy/helm/ and is not mentioned in deploy/README.md. |
| Manifest dependencies | PluginManifest.Dependencies is part of the contract but PluginLoader does not enforce missing-dependency disabling, despite the doc-comment claim. |
| Per-plugin enabled / disabled state | No persisted state, no admin toggle. The only way to disable a plugin is to remove the DLL. |
| Plugin context | IPluginContext is defined but never injected. Plugins receive no plugin-scoped logger and have to grab ILogger<T> themselves. |
Resolved decisions are folded into §3. These are the remaining concerns:
- Captive scoped hooks (resolved — keep singleton, fix differently).
Dispatcher stays singleton. Resolution: change the dispatcher to take
IHttpContextAccessor(or acceptIServiceProviderfromHttpContext.RequestServicesper call) and resolveIEnumerable<THook>from the request scope on each invocation. This keeps the dispatcher itself stateless+singleton while letting plugin hooks beScopedsafely. /plugins/{id}group has no host-level auth (resolved — enforce). The host appliesRequireAuthorization(AuthSetup.AdminPolicy)to every plugin route group by default; plugins do not get to opt out. If they need anonymous endpoints they have to request it explicitly via a new manifest field (AllowAnonymousEndpoints = true), which the host logs loudly at startup.Manifest.Dependenciesnot enforced (resolved — enforce). Loader runs a topo sort, skips modules with missing deps, surfaces a "disabled (missing dependency: X)" reason in the registry so the admin UI can show why.- No assembly isolation.
Assembly.LoadFrominto the default ALC means plugin DLLs share dependency versions with the host. Acceptable for v1, document as a constraint and keep on the v1.x list. - Loader silently rejects types without a parameterless ctor. Should
log a warning when an
IAdaptiveApiPlugin-implementing type is found but rejected. SetRawAsyncvalidation duplicated. DbPluginSettingsStore.SetRawAsync already validates JSON syntax; PluginEndpoints.UpdateSettings then catchesJsonExceptionfrom a path that's already checked. Move the validation up so the endpoint does input validation cleanly and the store trusts what it's given (or the reverse — pick one).IAiCallHook.AfterAsyncon streaming responses. Plugins reading the body would starve the client. Document explicitly in IAiCallHook.cs:13 that body inspection in this hook is unsupported for streamed responses, and add aIsStreamingflag onPipelineHookContextso plugins can branch.
- 0.1 Per-request hook resolution (keep dispatcher singleton).
Change PluginHookDispatcher
to take
IHttpContextAccessorand, on eachRun*Async, pullIEnumerable<THook>fromHttpContext.RequestServicesinstead of from the singleton's captured constructor list. The dispatcher itself stays singleton + stateless. Add anIHttpContextAccessorregistration in PluginServiceCollectionExtensions. Test:Scopedhook returns a fresh instance per request and resolves aScopedDbContextwithout throwing. - 0.2 Default-deny auth on plugin route groups. In
Program.cs:154-158 wrap the
plugin route group with
.RequireAuthorization(AuthSetup.AdminPolicy)before passing it tomodule.MapRoutes(...). Add an opt-out path:PluginManifest.AllowAnonymousEndpoints(defaultfalse); whentrue, the host skips the auth wrapper and logs a warning at startup with the plugin id and version, so the operator sees it on every restart. Document in the SDK XML docs that anonymous endpoints are an explicit trust signal — the host won't silently expose anything. - 0.3 Enforce
Manifest.Dependencies. In PluginLoader, after collection, topo-sort modules. For each module with at least one missing dep, exclude it from the runtime list and add a sidecarDisabledPluginEntry { PluginId, Version, Reason }returned alongsideDiscoveredPlugins. Pass that list into PluginRegistry so/admin/pluginscan surface "disabled — missing X". Cycle detection: log error, drop the cycle, keep going. - 0.4 Loader hardening. Warn on:
candidate types missing a parameterless ctor;
ReflectionTypeLoadException(currently silently truncated to whatever loaded); duplicate plugin ids across assemblies (drop the second, log). Same file as 0.3. - 0.5 Streaming clarity. Add
bool IsStreamingto PipelineHookContext and update XML docs on IAiCallHook.AfterAsync to state body inspection is unsupported whenIsStreamingis true. - 0.6 Settings validation cleanup. Pick one layer for JSON syntax validation (recommend the endpoint, since it's the input boundary) and drop the duplicate from DbPluginSettingsStore.SetRawAsync.
- 1.1 Per-plugin enabled state. Add a
PluginStateEntity(or extendPluginSettingsEntity) withEnabled bool, surfacePOST /admin/plugins/{id}/enableand/disable. Dispatcher checks state before invoking hooks (cache the lookup per request). - 1.2 Wire
IPluginContext. Either remove it (it's currently dead code) or implement: register a per-plugin keyedILoggerand pass anIPluginContextinstance into a newInitialize(IPluginContext ctx)hook, or expose the context via DI keyed by plugin id. - 1.3 Admin UI page. New
src/AdaptiveApi.Ui/src/pages/Plugins.vue:
list manifests + state, enable/disable toggle, per-plugin opaque-JSON
settings editor (Monaco-style textarea + format/validate button). Link
to plugin's own UI surface if
HasEndpoints. Add to router.ts. - 1.4 Tests. Plugin pieces are entirely untested. Minimum coverage:
PluginHookDispatcherTests— order, body threading, short-circuit, fail-open vs fail-closed, scoped hook resolution (after 0.1).PluginLoaderTests— discovery from AppDomain + directory scan, duplicate suppression, dependency enforcement, parameterless-ctor check.DbPluginSettingsStoreTests— round-trip, JSON validation rejects garbage, global vs (future) tenant scoping.- Integration: an end-to-end
RecordingPluginthat mutates request body and another that short-circuits, asserted againstOpenAiPassthroughTestsandGenericAdapterTestspatterns.
- 2.1 Author docs. New
docs/docs/plugins.html covering: manifest fields,
three hooks, fail-open vs fail-closed semantics, settings store, route
group conventions,
/admin/plugins/...endpoints, deployment options (AdaptiveApi.*.dllnext to host vsplugins/mount), and the no-ALC- isolation caveat. Update docs/docs/deployment.html#L160-L167 to point at it. - 2.2 Example plugin. Drop a runnable sample under
examples/sample-plugin/ — single project that
references
AdaptiveApi.Plugins.SDK, implements one request hook that prefixes a header, persists a single setting, and exposes one/plugins/sample-plugin/pingendpoint. Doubles as a smoke test for the loader and as a NuGet template seed. - 2.3 SDK packaging. Add
<PackageId>,<Version>,<TargetFramework>,<GenerateDocumentationFile>,<IsPackable>true,<PackageReadmeFile>to AdaptiveApi.Plugins.SDK.csproj, plus a CI step ordotnet pack-friendlyDirectory.Build.propsblock. Decide on a versioning convention (1.0.0-preview.x while the surface changes). - 2.4 Deploy surface. Add a
plugins/volume mount + readme note in deploy/docker/, deploy/docker-compose.yml, and deploy/helm/ values.
- 3.1 Per-tenant settings. Already pre-baked in the schema (composite
key +
tenantIdparameter). Light up once tenancy work elsewhere lands. - 3.2 Per-chunk streaming hooks. Currently out of scope per IAiCallHook.cs:13. Revisit when there's a concrete plugin that needs it.
- 3.3 Plugin observability. Per-plugin counters / latency histograms
in the dispatcher, surfaced via the existing audit pipeline or a new
/admin/plugins/{id}/metricsendpoint. - 3.4
AssemblyLoadContext-isolated plugin loading. Real isolation is a large change (collectable contexts, shared-type contracts) and is best left until a plugin actually hits a dep conflict.
Answered:
Singleton lifetime?→ Yes, keep singleton. Resolved via per-request hook resolution in 0.1.Plugin route group auth posture?→ Default-deny at the host with an explicit opt-out. Resolved in 0.2.Enforce→ Enforce. Resolved in 0.3.Manifest.Dependencies?
Still open:
- Should disabling a plugin persist across restarts (DB) or be
process-local? Persisted is the safer default; confirm before I add a
PluginEnabledtable or column. Touches 1.1. - Settings JSON: should plugins be able to publish a JSON Schema the admin UI can render typed forms from, or is the opaque-textarea editor the long-term plan? Affects 1.3 scope.
- Is the SDK going on NuGet (so third parties can build plugins out-of-tree) or staying repo-local for now? Affects 2.3.