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):

  1. The activator (AssemblyPluginLoader.LoadAsync) uses ActivatorUtilities.CreateInstance(_hostServices, entryPointType) against the host’s root IServiceProvider (line 141).
  2. The agent host (StellaOps.Agent.Host/Program.cs) registers every dependency every plugin’s constructor declares (IDeployProcessRunner, IArtifactFileSystem, IBuildxProcessRunner, IBuildAttestationEvidenceEmitter, ContainerExecutionPluginBackend, and an IAgentCapability composeCapability keyed 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:

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)

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:

FieldRequiredMeaning
contractyesFully-qualified type name (the .NET interface or abstract base). Registry lookup key.
versionyesSemver range (^1.0, >=1.0 <2.0, 1.*, exact 1.2.3). Compared against the registered contract’s [ContractVersion] attribute (see Part 2).
optionalno (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.
scopeno (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:

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:

ScopeLifetimeUse case
SingletonOne 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.
PerInvocationNew 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:

  1. Loader reads the plugin’s requires block from the manifest.
  2. For each requirement: ask the registry to Resolve(...). If optional: false and resolution returns null, throw PluginLoadException("required contract X v^1.0 unsatisfied — host has [list of registered versions or empty]").
  3. Build an isolated ServiceCollection:
    • For each resolved PerPlugin requirement → AddSingleton(contractType, instance) (singleton within this scoped provider, which is the plugin’s lifetime).
    • For each resolved Singleton requirement → AddSingleton(contractType, instance) (the registry returns the host-wide singleton).
    • For each resolved PerInvocation requirement → AddScoped(contractType, factory) (each ExecuteAsync call gets a fresh child scope).
    • Plus an injected IPluginContext (the same context object that InitializeAsync receives) so plugins can also use service-locator pattern via IPluginServices for back-compat.
  4. Build the scoped provider; call ActivatorUtilities.CreateInstance(scopedProvider, entryPointType).
  5. Stash the scoped provider on PluginAssemblyLoadResult for 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:

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 categoryBuiltInTrustedUntrusted
Process spawn (IDeployProcessRunner, IBuildxProcessRunner)yesyesno (Untrusted is process-sandboxed; spawning child processes from inside the sandbox is denied at the policy layer too)
Filesystem helpers (IArtifactFileSystem)yesyespolicy-gated (only the per-plugin scratch dir surface; mediated through IFilesystemPolicy on the context)
Evidence / telemetry emitters (IBuildAttestationEvidenceEmitter)yesyesyes (write-only; the host validates emitted records)
Container execution (ContainerExecutionPluginBackend)yesyesno (Untrusted plugins do not get to spawn container workloads)
Other plugin’s exported services (IAgentCapability composeCapability)yesyesno (cross-plugin service composition reserved for trusted plugins)
Stable infra (TimeProvider, ILoggerFactory)yesyesyes

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.

Migration sequence per plugin:

  1. Plugin author adds requires: [...] to the manifest listing the ctor dependencies.
  2. Host wiring switches from services.AddSingleton<IDeployProcessRunner, DefaultDeployProcessRunner>() to services.AddPluginContract<IDeployProcessRunner>(ctx => ..., scope: ..., allowedTrustLevels: ...).
  3. Plugin reloads; activator binds via Pattern Z; verification passes.
  4. The ambient services.AddSingleton<IDeployProcessRunner, ...>() registration is deleted from Program.cs once 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

Tradeoffs

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