A CLR-native implementation of Cordis semantics for .NET 10 / C# 14.
The project intentionally keeps the kernel small. It does not use Microsoft.Extensions.DependencyInjection, Generic Host, MediatR, Rx, MEF, or an actor framework. Cordis owns its own composition semantics.
Cordis.NET is built around six orthogonal primitives:
- Context overlay — hierarchical per-capability realm selection,
isolate, andintercept. - Service registry —
(service, realm) -> provider; provider visibility is independent from plugin lifetime. - Injection + Fiber — missing dependencies keep a plugin
Pending; provider changes unload/reload consumers by dependency epoch. - Effect scope — every activation owns a LIFO reversible effect stack and cancellation lifetime.
- Typed events — parallel, serial, bail, and waterfall are distinct typed event keys; listeners are effects.
- Config tree + Loader — stable IDs, groups, overlays, typed config codecs, plugin sources, and reload.
The core invariant is:
plugin/fiber lifetime != service visibility realm
A provider plugin can publish a service in a realm and a sibling consumer plugin in the same realm can resolve it. Removing/replacing the provider invalidates the consumer's dependency epoch and causes a clean effect unwind/reload.
src/
├── Cordis.Core/ # BCL-only composition kernel
└── Cordis.Loader/ # config tree, YAML, plugin sources, collectible assemblies
Cordis.Core is intentionally unaware of YAML, files, assemblies, Microsoft DI, and application frameworks.
A service definition owns the vocabulary and capability key:
public abstract class ShellService(Context context) : Service(context)
{
public static readonly ServiceKey<ShellService> Key = new("shell");
public abstract ValueTask<string> RunAsync(
string command,
CancellationToken cancellationToken);
}
public static class ShellContext
{
extension(Context context)
{
public ShellService Shell => context.Get(ShellService.Key);
}
}A provider implements the service and publishes it during its Fiber activation:
public sealed class LocalShellPlugin : CordisPlugin
{
public override PluginDescriptor Descriptor { get; } =
PluginDescriptor.Create("shell-local");
protected override ValueTask ApplyAsync(
Context context,
CancellationToken cancellationToken)
{
var shell = new LocalShellService(context);
context.Own(shell);
context.Provide(ShellService.Key, shell);
return ValueTask.CompletedTask;
}
}A consumer declares the dependency but imports no provider:
public sealed class BashToolPlugin : CordisPlugin
{
public override PluginDescriptor Descriptor { get; } =
PluginDescriptor.Create("tool-bash", ShellService.Key.Id);
protected override ValueTask ApplyAsync(
Context context,
CancellationToken cancellationToken)
{
// context.Shell is guaranteed available while this Fiber is Active.
return ValueTask.CompletedTask;
}
}Mount order is not semantically significant. If the consumer is mounted first, its Fiber remains Pending; publishing shell later activates it.
Context.Isolate() changes only selected capability realms:
var sandbox = root.Isolate(
ShellService.Key.Id,
FileSystemService.Key.Id);Conceptually:
root:
shell -> realm A
fs -> realm A
llm -> realm A
sandbox:
shell -> realm B
fs -> realm C
llm -> realm A
The physical providers live in the shared registry; Context stores only overlay decisions.
dependency missing
|
v
Pending
|
dependencies satisfied
v
Loading
|
+------------+------------+
| |
v v
Active Failed
|
epoch changed / dispose
v
Unloading
|
effects reverse LIFO
v
Pending or Disposed
Each activation epoch has its own:
CancellationTokenEffectScope- dependency generation snapshot
Provider removal is itself an effect, so consumers are invalidated before the old provider generation is discarded.
public static class Events
{
public static readonly EventKey<TurnStarted> TurnStarted = new("turn/started");
public static readonly WaterfallEventKey<Prompt> Prompt = new("prompt");
}
ctx.On(Events.TurnStarted, async (_, evt, ct) => { ... });
await ctx.EmitAsync(Events.TurnStarted, evt, ct);
ctx.On(Events.Prompt, async (_, prompt, ct) => prompt with { ... });
prompt = await ctx.WaterfallAsync(Events.Prompt, prompt, ct);Listeners are owned by the current Fiber effect scope. Once, Prepend, global listeners, event realms, and context filters are supported.
Direct-list YAML:
- id: sandbox-world
name: cordis:group
group: true
isolate:
- shell
- fs
children:
- id: shell
name: app:shell-docker
config:
image: ubuntu:24.04
- id: bash-tool
name: app:tool-bashThe loader also accepts an entries: wrapper.
Stable IDs distinguish update from remove+add. A group is a composition node, not a runtime manager. It derives a Context overlay and mounts children into that environment.
AssemblyPluginSource uses the BCL mechanisms designed for .NET plugins:
- collectible
AssemblyLoadContext AssemblyDependencyResolver
A dynamic plugin assembly exposes exactly one parameterless IPluginModule. The module returns a PluginRegistration containing the plugin factory and config codec.
Assembly unloading is only generation mechanics. Cordis semantics remain:
Fiber unload
-> effect reversal
-> service withdrawal
-> dependent Fiber transitions
-> plugin generation released
Cordis.Loader keeps raw config as JsonNode; the plugin boundary owns typed binding through IPluginConfigCodec / JsonPluginConfigCodec<T> and source-generated JsonTypeInfo<T>.
This keeps Loader independent of plugin-specific configuration types.
Requires .NET 10 SDK.
dotnet build Cordis.NET.slnNo test project is included.