Skip to content

[RFC #5] Conflict resolution semantics between plugins #101

Description

@ChrisonSimtian

RFC #5 — Conflict resolution semantics between plugins

Self-RFC. Pins what happens when two plugins step on each other.

Context

In a healthy ecosystem, two plugins eventually do conflicting things — register an IHost for the same CI environment, add middleware at the same (phase, priority) slot, contribute a tool wrapper with the same name. The host needs one consistent answer per conflict shape. The worst answer is silent winner-takes-all — it produces "why doesn't my plugin work" tickets with no useful error.

Proposed default: throw with a clear message

For every extension point, the default on conflict is to throw FalloutPluginConflictException at startup, naming both plugins and the colliding resource, with exact resolution steps.

Example:

Plugin conflict: two plugins registered IHost for environment 'GitHubActions':
  - Fallout.Plugin.GitHubActions   (1.2.0, from your PackageReference)
  - Fallout.Plugin.CICustom        (0.4.1, from your PackageReference)

Resolve by:
  (a) Removing one of the PackageReferences from _build.csproj, or
  (b) In _build.csproj, set <FalloutPluginPrecedence>Fallout.Plugin.GitHubActions</FalloutPluginPrecedence>
      to declare which plugin wins, or
  (c) File a bug against one of the plugins if they should coexist.

Per-extension-point semantics

Not every extension point conflicts the same way:

Extension point Conflict semantics
IBuildMiddleware No conflict. Multiple middleware at a phase coexist, run in priority order. Same priority → alphabetical-by-plugin-id breaks the tie.
IHost + IHostDetector Conflict if two detectors match the current env. Throws.
IParameterSource No conflict. Sources run in order; first non-null wins. Order via [Order].
IToolWrapperContribution Conflict if two plugins contribute the same *Tasks type name. Throws.
ITargetLifecycleListener No conflict. All listeners run on every event.
IOutputSink No conflict. All sinks receive every event.

Pattern: "broadcast" points (IBuildMiddleware, ITargetLifecycleListener, IOutputSink, IParameterSource) can't conflict by design. "Single-winner" points (IHost, IToolWrapperContribution) throw.

User-facing precedence escape hatch

For the throwing cases, declare precedence in _build.csproj:

<ItemGroup>
  <FalloutPluginPrecedence Include="Fallout.Plugin.GitHubActions" />
</ItemGroup>

Deliberate escape — for when two plugins should both be installed but one wins on conflict. Not for accidental conflicts; resolve those by removing the unused plugin.

Why not "highest version" or "newest install" wins?

  • Highest version: version numbers carry no cross-plugin meaning. A 2.0 may predate another's 1.0.
  • Newest install: depends on PackageReference order, unstable across restores.
  • First alphabetically: arbitrary; no useful signal.

Throwing is the only behaviour where the user is guaranteed to know there's a conflict.

Questions for discussion

  1. Ship dotnet fallout plugins conflicts to preview conflicts for the current PackageReference set without a build? Default: yes. Disagree?
  2. Precedence escape hatch in _build.csproj or a separate fallout-plugins.json? Default: csproj — keeps build config in one place. Disagree?
  3. Anything in the "broadcast" column that should be single-winner, or vice versa? It's a judgment call.

Out of scope (other RFCs)

How to engage

Comment if you've maintained a plugin ecosystem that got conflict resolution wrong (Jenkins, VS extensions, Cake addins, MSBuild SDKs). Lessons from those are the most useful input.

Decision lands in

When this RFC locks (2026-08-31), its shape becomes the spec for:

Metadata

Metadata

Assignees

No one assigned

    Labels

    RFCDesign discussion / RFC. Comment with feedback; consensus shapes the implementation.target/vNextTargets the next calendar-version

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions