ADR-011: EPF plugin service contract — manifest-declared requires + host service registry (Pattern Z)
Status: Accepted (design-only; execution operator-greenlit later) Date: 2026-05-29 Sprint: SPRINT_20260529_052_EPF_manifest_declared_service_contract_rfc.md (S052-001) Related ADRs: ADR-006 (Execution Plugin Framework — the design this ADR completes). Related sprints: Sprint 049 (DI activation baseline — Pattern Y constructor activation via ActivatorUtilities); Sprint 050 (host-side ambient registration of the 5 Sprint 017 plugins’ deps — Pattern Y tactical landing); Sprint 017 (the EPF live forcing-function that Pattern Z eventually unblocks at scale). Related code: src/Plugin/StellaOps.Plugin.Abstractions/Manifest/PluginManifest.cs (v2 manifest, target of the additive requires block); src/Plugin/StellaOps.Plugin.Host/Loading/AssemblyPluginLoader.cs:139-156 (current Pattern Y activator); src/Plugin/StellaOps.Plugin.Abstractions/Context/IPluginContext.cs:103-134 (existing IPluginServices service locator — kept, narrowed); src/Plugin/StellaOps.Plugin.Abstractions/PluginTrustLevel.cs (the trust matrix this ADR extends).
In one line: Stella Ops Execution Plugin Framework (EPF) plugins should declare the host services they consume in their signed manifest, and the host should hand each plugin only those services — restoring the ADR-006 “no host edits” promise and closing the auditability and security gaps that ambient host-side DI (Pattern Y) leaves open.
Audience: platform engineers working on the plugin host and SDK, and reviewers assessing the EPF security boundary. Assumes familiarity with ADR-006.
Context
ADR-006 sets the EPF promise (line 84):
“New execution behaviours (new deploy backend, build step, custom-language step) are added as signed, isolated, resource-limited plugins with no host edits.”
Sprint 049 + Sprint 050 take the tactical fast path — Pattern Y (ambient host-side DI):
- The activator (
AssemblyPluginLoader.LoadAsync) usesActivatorUtilities.CreateInstance(_hostServices, entryPointType)against the host’s rootIServiceProvider(line 141). - The agent host (
StellaOps.Agent.Host/Program.cs) registers every dependency every plugin’s constructor declares (IDeployProcessRunner,IArtifactFileSystem,IBuildxProcessRunner,IBuildAttestationEvidenceEmitter,ContainerExecutionPluginBackend, and anIAgentCapability composeCapabilitykeyed dep).
Pattern Y ships the 5 Sprint 017 plugins today. It also violates the ADR-006 “no host edits” promise: adding a new EPF plugin that declares a new ctor dependency requires editing Program.cs to register that dependency. The “external plugins discovered + loaded at runtime” intent of ADR-006 degrades to “external plugins whose dependency graphs are already known at host compile time”.
Two architectural defects compound:
- Auditability gap. A plugin’s runtime service surface is not derivable from its manifest. To know what a plugin can touch at runtime, an operator must read the plugin’s source, list its ctor parameters, then cross-reference the host’s
Program.cs. The signature covers the bytes; nothing covers the dependency graph. - Security gap. Every plugin loaded via Pattern Y gets the host root
IServiceProviderand can resolve anything registered in it — not just the services its manifest implies. The trust matrix (BuiltIn/Trusted/Untrusted) bites at the ALC and process-sandbox layers; it does NOT bite at the DI layer. AnUntrustedplugin that happened to compile against a host-registeredIDatabaseConnectioncould resolve it.
H4 architectural analysis 2026-05-29 surfaced both gaps and the operator confirmed the chosen direction: “go Y+Z” — Pattern Y for the immediate Sprint 017 unblock, Pattern Z as the strategic completion that retires Pattern Y when prod 3rd-party-plugin support becomes a real driver.
Inventory (verified 2026-05-29 against feat/execution-plugin-platform HEAD)
- In-repo EPF plugins (Pattern Y consumers): 4 native deploy/build (
StellaOps.Agent.BuildDocker,StellaOps.Agent.BuildScript,StellaOps.Agent.DeployCompose,StellaOps.Agent.DeployNative). The agent host registers their 6 dependencies explicitly. - Plugin SDK service-locator surface:
IPluginContext.Services(IPluginServices—GetRequiredService<T>/GetService<T>/GetServices<T>/CreateScope) already exists atsrc/Plugin/StellaOps.Plugin.Abstractions/Context/IPluginContext.cs:103-134. It is a thin facade over an unconstrainedIServiceProvider. Pattern Z narrows what this facade is allowed to resolve, rather than replacing it. - Manifest v2: has
dependencies(other plugins this plugin needs — runtime dependency on PluginA),permissions(string permission tokens),capabilities(what this plugin provides),execution(the EPF-specific provided-capability declaration). There is no declaration of services this plugin consumes. That is the gap Pattern Z fills. - Manifest signing: the bundle (Sprint 047) signs the manifest bytes. Any field added to the manifest is automatically signed. The
requiresblock joins that perimeter for free — tampering with declared service requirements breaks signature verification.
Decision
Adopt Pattern Z as the strategic EPF plugin service contract. Pattern Y stays as the transitional baseline; both coexist during migration; Pattern Y deprecates and is removed once all in-repo plugins have migrated.
Pattern Z has four parts: (1) a manifest extension that declares required services, (2) a host service registry that exposes services to plugins under contract identity, (3) an activator integration that builds a per-plugin scoped IServiceProvider from the registry (NOT from the host root), and (4) a trust-level service-access matrix that gates registry access by trust verdict.
Part 1 — Manifest extension: requires block
Additive field on PluginManifest (no v2→v3 schema bump required — additive, optional, ignored by older readers):
{
"info": { "id": "stellaops.agent.deploy.native", ... },
"entryPoint": "StellaOps.Agent.DeployNative.DeployExecExecutionPlugin",
"execution": { "kind": "Deploy", "capabilities": ["deploy.exec"] },
"requires": [
{
"contract": "StellaOps.Agent.DeployNative.IDeployProcessRunner",
"version": "^1.0",
"optional": false,
"scope": "perPlugin"
},
{
"contract": "StellaOps.Agent.DeployNative.IArtifactFileSystem",
"version": "^1.0",
"optional": false,
"scope": "perPlugin"
},
{
"contract": "StellaOps.Agent.BuildDocker.IBuildAttestationEvidenceEmitter",
"version": "^1.0",
"optional": true,
"scope": "perPlugin"
}
]
}
Field semantics:
| Field | Required | Meaning |
|---|---|---|
contract | yes | Fully-qualified type name (the .NET interface or abstract base). Registry lookup key. |
version | yes | Semver range (^1.0, >=1.0 <2.0, 1.*, exact 1.2.3). Compared against the registered contract’s [ContractVersion] attribute (see Part 2). |
optional | no (default false) | If true: plugin still loads when the host has no matching registration; the ctor parameter binds to null (nullable) or the default value. If false: missing registration is a hard PluginLoadException. |
scope | no (default perPlugin) | One of perPlugin (one instance shared across all calls into the plugin instance), perInvocation (new instance per ExecuteAsync call — short-lived state, e.g. per-deploy work dir), singleton (one shared instance across all plugins — for stable shared infra like TimeProvider). |
Contract identifier scheme — decision: fully-qualified type name. A separate string-id contract registry was considered (e.g. "contract": "stellaops.deploy.process-runner") and rejected — the FQ-type-name is the only identifier the activator can use to bind a ctor parameter by Type without a separate type-resolution step, and the .NET ALC model already gives us a tamper-resistant identity (assembly name + type name) for free. Cross-ALC type identity is handled by the host pinning the contract types in the default ALC and the plugin ALC’s Resolving hook routing the contract assembly back to the host load (see the feedback_default_alc_tpa_list engineering note for the prior art).
Versioning enforcement: the host marks each registered contract with [ContractVersion("1.2.0")] (a new attribute in StellaOps.Plugin.Abstractions). The registry rejects any requires.version range that does not match the registered version. Contract evolution rules:
- Patch (1.0.0 → 1.0.1): bug fix, no API change. Existing
^1.0plugins keep working. - Minor (1.0.0 → 1.1.0): additive only (new methods, new optional params). Existing
^1.0plugins keep working. - Major (1.0.0 → 2.0.0): breaking change. Existing
^1.0plugins fail to load with a clearPluginLoadException("contract X is at version 2.0.0; plugin requires ^1.0 — manifest update needed"). The host MAY register both1.xand2.xsimultaneously during a deprecation window (one factory per version), and the registry resolves by version match.
Validation: the manifest’s Validate() method is extended to check that every requires.contract is a non-empty FQ-type-name and every requires.version is a parseable semver range. Bundle producer (Sprint 047) signs the augmented manifest — any post-sign tampering with the requires block fails verification.
Part 2 — Host service registry: IPluginServiceRegistry
A new singleton in StellaOps.Plugin.Host. The registry is the only path from a host service to a plugin’s DI graph. The host’s root IServiceProvider is invisible to plugins under Pattern Z.
namespace StellaOps.Plugin.Host.Services;
/// <summary>
/// Single seam through which host-registered services are exposed to plugins.
/// Plugins resolve services from this registry by manifest-declared contract identity,
/// NOT from the host root IServiceProvider.
/// </summary>
public interface IPluginServiceRegistry
{
/// <summary>
/// Registers a contract implementation at host startup. The contract type's
/// [ContractVersion] attribute pins the registered version. The factory is
/// called per-resolution; scope governs lifetime semantics.
/// </summary>
void Register<TContract>(
Func<IPluginContext, TContract> factory,
ContractScope scope = ContractScope.PerPlugin,
IReadOnlySet<PluginTrustLevel>? allowedTrustLevels = null)
where TContract : class;
/// <summary>
/// Resolves a contract for a plugin activation. Returns null if no registration
/// matches AND the requirement was optional. Throws PluginLoadException if
/// the requirement was required and unresolvable, OR if the plugin's trust
/// level is not in the contract's allowedTrustLevels.
/// </summary>
object? Resolve(
PluginRequirement requirement,
IPluginContext callingPluginContext,
PluginTrustLevel callingPluginTrust);
/// <summary>
/// Snapshot of every registered contract for audit / introspection.
/// Exposed via /platform/admin/plugin-registry (Trusted-only).
/// </summary>
IReadOnlyList<RegisteredContract> Snapshot();
}
public enum ContractScope { PerPlugin, PerInvocation, Singleton }
public sealed record RegisteredContract(
string ContractTypeName,
string Version,
ContractScope Scope,
IReadOnlySet<PluginTrustLevel> AllowedTrustLevels);
public sealed record PluginRequirement(
string ContractTypeName,
string VersionRange,
bool Optional,
ContractScope RequestedScope);
Registration ergonomics: a service-collection extension lets host wiring stay terse —
// In StellaOps.Agent.Host/Program.cs (Pattern Z replacement for current Pattern Y wiring):
builder.Services.AddPluginContract<IDeployProcessRunner>(
ctx => new DefaultDeployProcessRunner(ctx.Logger),
scope: ContractScope.PerPlugin,
allowedTrustLevels: PluginTrustLevels.All);
builder.Services.AddPluginContract<IBuildAttestationEvidenceEmitter>(
ctx => NoOpBuildAttestationEvidenceEmitter.Instance,
scope: ContractScope.Singleton,
allowedTrustLevels: PluginTrustLevels.BuiltInAndTrusted);
The host is what changes between releases (adding/removing contracts, broadening/narrowing trust). Plugins do not change unless their manifest’s required contracts change — which is the ADR-006 promise.
Scope semantics:
| Scope | Lifetime | Use case |
|---|---|---|
Singleton | One instance for the host’s lifetime, shared across all plugins. | Stable infra: TimeProvider, ILoggerFactory, signing-key provider. |
PerPlugin (default) | One instance per plugin activation; lives for the plugin’s lifetime. | Stateful-per-plugin helpers: IDeployProcessRunner, IArtifactFileSystem. |
PerInvocation | New instance per ExecuteAsync call; disposed on completion. | Per-execution state: scratch dirs, request-correlation contexts. The activator builds a child IServiceProvider scope around the call and disposes it on ExecuteAsync return. |
Part 3 — Activator integration: per-plugin scoped IServiceProvider built FROM the registry
The current Pattern Y path (AssemblyPluginLoader.LoadAsync lines 139-156) calls:
instance = (IPlugin)ActivatorUtilities.CreateInstance(_hostServices, entryPointType);
Pattern Z replaces _hostServices with a per-plugin scoped IServiceProvider built from the registry:
- Loader reads the plugin’s
requiresblock from the manifest. - For each requirement: ask the registry to
Resolve(...). Ifoptional: falseand resolution returns null, throwPluginLoadException("required contract X v^1.0 unsatisfied — host has [list of registered versions or empty]"). - Build an isolated
ServiceCollection:- For each resolved
PerPluginrequirement →AddSingleton(contractType, instance)(singleton within this scoped provider, which is the plugin’s lifetime). - For each resolved
Singletonrequirement →AddSingleton(contractType, instance)(the registry returns the host-wide singleton). - For each resolved
PerInvocationrequirement →AddScoped(contractType, factory)(eachExecuteAsynccall gets a fresh child scope). - Plus an injected
IPluginContext(the same context object thatInitializeAsyncreceives) so plugins can also use service-locator pattern viaIPluginServicesfor back-compat.
- For each resolved
- Build the scoped provider; call
ActivatorUtilities.CreateInstance(scopedProvider, entryPointType). - Stash the scoped provider on
PluginAssemblyLoadResultfor symmetric disposal at unload time.
Critical: the host root IServiceProvider is never passed to the plugin. A plugin can resolve only what its manifest declares. This is enforced by the activator change, not by trust-level policing at runtime — it is structurally impossible for a plugin to reach beyond its declared surface because the surface is the only DI graph it sees.
Back-compat: IPluginServices (the existing service-locator at IPluginContext.Services) is preserved. It is re-pointed from the host root to the per-plugin scoped provider. Pattern Y plugins (no requires block in manifest) continue to resolve from _hostServices via the legacy code path — gated by a feature flag PluginHost:EnablePatternZ (default true for new installs, false initially during migration). Plugin authors migrating manifest-by-manifest can flip the flag per plugin via PluginHost:PatternZPluginIds: ["stellaops.agent.deploy.native", ...] allow-list.
Per-invocation scope (ContractScope.PerInvocation): the agent host’s IExecutionPlugin.ExecuteAsync dispatcher wraps each call in using var scope = pluginScopedProvider.CreateScope() and passes scope.ServiceProvider into a refreshed IPluginContext. The plugin’s ctor-bound PerInvocation services are NOT directly accessible from ExecuteAsync (ctor binding is one-shot at load); the pattern is:
- Stateless per-call dependency: declare
scope: perInvocation, resolve viacontext.Services.GetRequiredService<T>()insideExecuteAsync. The scoped provider returns a fresh instance per call. - Long-lived plugin dependency: declare
scope: perPlugin, bind via ctor, hold as a field.
This mirrors ASP.NET Core’s request-scoped vs singleton-scoped split and uses the same IServiceScopeFactory machinery.
Part 4 — Trust-level service-access matrix
Every contract registration carries an allowedTrustLevels set. The registry refuses to resolve a contract for a plugin whose trust level is not in the set, returning null if the requirement is optional: true or throwing PluginLoadException("contract X not available to Untrusted plugins") if required.
Default policy matrix (host wiring decision, NOT a hardcoded SDK default):
| Contract category | BuiltIn | Trusted | Untrusted |
|---|---|---|---|
Process spawn (IDeployProcessRunner, IBuildxProcessRunner) | yes | yes | no (Untrusted is process-sandboxed; spawning child processes from inside the sandbox is denied at the policy layer too) |
Filesystem helpers (IArtifactFileSystem) | yes | yes | policy-gated (only the per-plugin scratch dir surface; mediated through IFilesystemPolicy on the context) |
Evidence / telemetry emitters (IBuildAttestationEvidenceEmitter) | yes | yes | yes (write-only; the host validates emitted records) |
Container execution (ContainerExecutionPluginBackend) | yes | yes | no (Untrusted plugins do not get to spawn container workloads) |
Other plugin’s exported services (IAgentCapability composeCapability) | yes | yes | no (cross-plugin service composition reserved for trusted plugins) |
Stable infra (TimeProvider, ILoggerFactory) | yes | yes | yes |
The matrix is expressed in the host wiring (builder.Services.AddPluginContract<T>(..., allowedTrustLevels: ...)) so operators can tune per deployment. The registry enforces; the SDK contracts are policy-neutral.
Implementation note: allowedTrustLevels is a HashSet<PluginTrustLevel>. The pre-defined sets PluginTrustLevels.All, PluginTrustLevels.BuiltInAndTrusted, PluginTrustLevels.BuiltInOnly, PluginTrustLevels.None (for never-exposed contracts) are syntactic sugar.
Migration path: Pattern Y → Pattern Z
Pattern Y stays alive during the deprecation window — back-compat is preserved at the loader.
- Plugin manifest has no
requiresblock → loader falls back to current Pattern Y activation against_hostServices. Exact behaviour as Sprint 049/050. - Plugin manifest has a
requiresblock → loader uses Pattern Z activation against the scoped provider built from the registry.
Migration sequence per plugin:
- Plugin author adds
requires: [...]to the manifest listing the ctor dependencies. - Host wiring switches from
services.AddSingleton<IDeployProcessRunner, DefaultDeployProcessRunner>()toservices.AddPluginContract<IDeployProcessRunner>(ctx => ..., scope: ..., allowedTrustLevels: ...). - Plugin reloads; activator binds via Pattern Z; verification passes.
- The ambient
services.AddSingleton<IDeployProcessRunner, ...>()registration is deleted fromProgram.csonce the contract is registered via the registry and all plugins consuming it have migrated.
Roslyn analyzer (deferred to Phase 4): an analyzer that scans StellaOps.Agent.Host/Program.cs for AddSingleton<T>(...) calls where T matches a contract also registered via AddPluginContract<T>(...) warns “ambient registration shadows plugin registry — remove after all Pattern Y plugins migrate”. This catches the half-migrated state.
Build-time manifest validator: the bundle producer (Sprint 047) plugin-pack CLI gains a --require-pattern-z flag (off by default) that fails the build if the manifest has no requires block. Once Phase 4 lands, this becomes the production default in CI.
Consequences
Positive
- ADR-006 “no host edits” promise restored. A new EPF plugin that declares only known contracts loads without touching
Program.cs. New contracts require host edits (correctly — they extend the platform’s exposed surface), but new plugins over the existing contract surface do not. - Auditable runtime service surface. A plugin’s manifest fully describes what host services it can touch. Operators audit by reading the manifest, not by cross-referencing source code against
Program.cs. The signed bundle covers the dependency graph for free. - Structural security boundary at the DI layer. A plugin cannot resolve a service it did not declare. The trust matrix bites at registration time, not at runtime — making the policy uniform across in-process, isolated-ALC, and process-sandboxed plugins.
- Pattern Y stays as a graceful migration path. No big-bang rewrite; in-repo plugins migrate one at a time; production stays green throughout.
- Same machinery serves 1st-party and 3rd-party plugins. The Sprint 017 plugins are the first consumers; future 3rd-party plugins (when prod demand surfaces) use the identical contract.
Tradeoffs
- Contract type-identity across ALCs. Plugins in isolated ALCs cannot bind to contract types loaded only in the host’s default ALC unless the plugin ALC’s
Resolvinghook is set to route contract assemblies back to the host. The Sprint 049 baseline already installs such a hook forStellaOps.*transitive deps (see thefeedback_default_alc_tpa_listengineering note); Pattern Z extends the rule to cover contract assemblies declared in therequiresblock. Trade-off: the contract assemblies become part of the host’s stable ABI surface — versioning them carelessly breaks all plugins consuming them. Mitigation:[ContractVersion]+ semver enforcement at the registry layer (Part 1). - Contract semver alone is insufficient if methods evolve incompatibly. OSGi / JPMS prior art shows this is solvable but non-trivial. Mitigation: contract assemblies live in
StellaOps.Plugin.Contracts.*namespaces with strict additive-only minor-version rules; major-version bumps coexist (StellaOps.Plugin.Contracts.Deploy.V1/V2). - Per-invocation scoping cost. Each
ExecuteAsynccall creates+disposes a scope. For sub-second invocations this is negligible; for very-high-frequency plugin calls (e.g. a futurescan.*plugin called per-artifact in a 10k-artifact pipeline) the scope cost is measurable. Mitigation:PerPluginscope is the default;PerInvocationis opt-in for the cases that truly need it. - Migration churn over the deprecation window. 4 plugins × manifest edit + 6 contract migrations from
AddSingleton<T>()toAddPluginContract<T>(...)+ analyzer rollout. Bounded; mechanical; covered by Phase 3 of the execution roadmap (below). - Operator-facing complexity. A new operator now has TWO ways to reason about plugin services (Pattern Y ambient DI and Pattern Z manifest-declared). Mitigated by deprecating Pattern Y in Phase 4. The deprecation window is the only period where both exist.
Alternatives Considered
(A) Status quo — Pattern Y everywhere; document the host-edit cost as an accepted tradeoff
Rejected. The ADR-006 “no host edits” promise is load-bearing for 3rd-party plugin support, which is a stated product direction. Accepting permanent host edits per plugin means every 3rd-party plugin author asks Stella Ops engineering to merge a Program.cs patch — non-scalable.
(B) JSON-based service descriptor outside the manifest (e.g. services.json sidecar)
Rejected. Splitting the contract surface between two files breaks the signature perimeter (would have to sign both files separately) and the operator-discoverability story (now you must read two files to know what a plugin can touch). The manifest is the canonical artifact; the requires block belongs in it.
© Service-locator-only (extend IPluginServices.GetRequiredService<T> without manifest declarations)
Rejected. IPluginServices already exists and is the back-compat fallback (Pattern Z preserves it). But making it the only path leaves the auditability and security gaps intact — a plugin can resolve anything its source code asks for; nothing in the manifest constrains it. Manifest declaration is the contract.
(D) DI source-generator that scans plugin ctors at build time and emits a requires block automatically
Rejected for v1. A useful future ergonomic — eliminates the manual manifest edit — but ADR-011 must establish the runtime semantics first. A source-generator can be layered on top in a follow-up sprint once the contract is stable and consumed by enough plugins to justify the tooling.
(E) Replace IPluginServices entirely with constructor-injection-only
Rejected. The service-locator pattern is the right escape hatch for runtime-conditional dependencies (e.g. “if IExperimentalFeatureGate is registered, opt in to the feature”). Pattern Z narrows what the locator can return (only registry-registered contracts); it does not remove the locator.
(F) Use the existing dependencies block (plugin-to-plugin deps) to also express service requirements
Rejected. PluginDependency is “this plugin requires PluginA to also be loaded” — a runtime composition concern. Service requirements are a DI / contract concern. Conflating them obscures both. They are kept separate.
References
docs/architecture/decisions/ADR-006-execution-plugin-framework.md— the design this ADR completes.docs/implplan/SPRINT_20260529_052_EPF_manifest_declared_service_contract_rfc.md— this sprint’s dossier; carries the execution roadmap (S052-002).docs/implplan/SPRINT_20260529_049_*(archived) — Pattern Y activator baseline (ActivatorUtilities.CreateInstanceinAssemblyPluginLoader).docs/implplan/SPRINT_20260529_050_ReleaseOrchestrator_agent_host_plugin_service_wiring.md— Pattern Y tactical landing (the 6 host registrations the 5 plugins need today).src/Plugin/StellaOps.Plugin.Abstractions/Manifest/PluginManifest.cs— manifest v2 (target of the additiverequiresblock).src/Plugin/StellaOps.Plugin.Abstractions/Context/IPluginContext.cs:103-134— existingIPluginServicesservice-locator surface (re-pointed under Pattern Z, not replaced).src/Plugin/StellaOps.Plugin.Host/Loading/AssemblyPluginLoader.cs:139-156— current Pattern Y activator; Pattern Z extension point.src/Plugin/StellaOps.Plugin.Abstractions/PluginTrustLevel.cs— trust enum extended by the matrix in Part 4.feedback_default_alc_tpa_list— engineering note on cross-ALC type identity; informs the contract-type ABI rule in Tradeoffs.- OSGi service registry pattern — industry prior art for declarative service binding; informs the contract-versioning rules.
- ASP.NET Core
IServiceScopeFactory— prior art for the per-invocation scope model.
