Cordis's API is not complicated:
Plugins declare their dependencies via inject:
Registration inside a plugin is done through interfaces such as ctx.effect(), ctx.on() and ctx.provide().
The part that truly determines what this system can do sits below the API: how Context, Fiber, the Service Registry and Effect cleanup work together to maintain a constantly changing plugin graph.
This article follows the source code directly to analyze that chain.
A Context Doesn't Hold an Ordinary Map
Cordis's Context is a Proxy-backed dependency container.[2]
When the root Context is created, it installs a few basic services:
Context
├── fiber
├── reflect
├── registry
├── events
└── logger
Ordinary property access eventually reaches the Service Resolver, so plugin code can simply write:
without needing:
That's only a difference in API form. More important is that a Context also carries Scope information.
extend() creates a child Context; isolate() lets a given Service name enter an independent scope within a subtree; intercept() attaches Service-specific config to lower-level plugins.[2]
So a Context is closer to:
service namespace
+
scope
+
lifecycle owner
+
event environment
Every Plugin Fiber derives its own Context from its parent:
That way, the effects a plugin produces are automatically attributed to the current Fiber.[1]
A Fiber Is the Running Instance of a Plugin
ctx.plugin() ultimately creates a Fiber.
The current source defines the following states for a Fiber:
PENDING
LOADING
ACTIVE
FAILED
UNLOADING
DISPOSED
A Fiber holds:
plugin runtime
config
inject declarations
resolved dependency implementations
owned effects
current epoch
in-flight lifecycle transition
So it carries one more layer of responsibility than a simple PluginHandle: maintaining the validity of the plugin in its current dependency environment.[1]
After a plugin declares:
the Fiber resolves the current implementations of those two Services and stores them in _store.
If either dependency is missing:
tools = available
llm = missing
the Fiber's epoch becomes:
__INACTIVE__
and the plugin stays PENDING.
Only when all dependencies are available does the epoch consist of the uid of the current Service Provider Fiber:
:12:27
This detail matters.
The dependency check doesn't merely test whether the Service name exists; it also encodes "which implementation is currently providing this Service" into the epoch.
So these two states are treated as different:
tools provided by Fiber 12
and:
tools provided by Fiber 31
Even though the Service name is still tools, a change of Provider triggers a reload of the plugin.[1]
Service Registration Is Itself an Effect
ctx.provide(name, value) doesn't simply write data into a Map.
Internally it registers an Effect owned by the current Fiber through:
On load:
store[key] = implementation
If the Provider Fiber is already ACTIVE at that point, the registration immediately notifies dependents; if the Service is registered during the plugin's LOADING phase, Cordis waits for the Fiber to become ACTIVE and then publishes the Services provided by that Fiber as part of the state transition. That way dependents aren't activated prematurely, before the Provider itself has finished initializing.[1][2]
On unload, the disposer will:
delete store[key]
notify(name)
This forms a critical lifecycle relationship:
Fiber owns Service
↓
Fiber unload
↓
Service removed automatically
A plugin no longer has to maintain separately:
The Service's lifetime is naturally bound to the Fiber that provides it.
Event listeners, accessors, mixins and similar capabilities also use the same kind of effect ownership.
This is exactly how Temporal Composability from the paper is most directly implemented in Cordis.
Why B Automatically Breaks When A Is Unloaded
Now let's analyze a dependency chain:
Plugin A
└── provides serviceA
Plugin B
├── injects serviceA
└── provides serviceB
Plugin C
└── injects serviceB
The initial state:
A ACTIVE
↓ serviceA
B ACTIVE
↓ serviceB
C ACTIVE
When A is unloaded, A's Service Effect starts disposing.
The disposer corresponding to ctx.provide('serviceA') first deletes the implementation:
and then calls:
notify() checks the Fibers in the current Registry and finds the consumers that declared the corresponding inject. For B, that means:[2]
serviceA changed
↓
B._checkImpl('serviceA')
↓
implementation missing
↓
delete B._store.serviceA
↓
B._refresh()
_refresh() recomputes B's epoch.
Because the dependency is missing:
epoch = __INACTIVE__
Then _setEpoch() finds:
old epoch = active dependency epoch
new epoch = INACTIVE
So B enters:
UNLOADING
and executes _unload().[1]
This process answers a question that's easy to misunderstand: when a Provider is unloaded, it doesn't need to find and manually destroy every downstream plugin.
The Provider only withdraws the Service it provides; the Service Registry notifies the Fibers that depend on that Service, and each Fiber decides whether to go inactive based on its own dependency declarations.
Why B's Unload Goes On to Affect C
B's _unload() cleans up the Effects owned by B.
Among them:
provide(serviceB)
So during B's unload, serviceB is removed as well, which triggers once again:
And C's dependency state changes:
serviceB disappears
↓
C._refresh()
↓
C epoch = INACTIVE
↓
C unload
The full chain becomes:
A unload
↓
serviceA removed
↓
B invalidated
↓
B unload
↓
serviceB removed
↓
C invalidated
↓
C unload
There's also an easily overlooked waiting relationship. The disposer of ctx.provide() waits for the affected dependency Fibers to await() a stable state after deleting the Service and calling notify(). So A's serviceA disposer waits for B to finish this round of lifecycle coordination, and B, when it cleans up serviceB, in turn waits for C to stabilize.[2]
The asynchronous relationship can be drawn as:
This explains why the resources along a dependency chain aren't simply cleaned up "all at once." The Service has already become unavailable to new resolution first, and downstream Fibers then complete their own teardown; the upstream disposer waits for the directly affected Fiber to stabilize. The dependency relationship keeps propagating downward through the Service provided at each level.
This is indeed a cascade, but not an explicitly written recursive algorithm.
Each level handles only two local facts:
Service changed
Fiber dependencies changed
New Service changes keep triggering the next level, until the system reaches a stable state with no further dependency changes.
So a more accurate description is: Cordis drives local reconciliation through Service change events, and the dependency chain propagates naturally through a series of local state transitions.
The current vendored implementation of notify() iterates over the Fibers in the Registry and then filters the affected ones by inject and isolation scope. Semantically it's a local update keyed by dependency name, but the implementation doesn't require a fully maintained reverse dependency graph.[2]
Does Unloading Happen Immediately
Once a Service is removed, the dependent Fiber immediately starts a lifecycle transition, but the unload process itself can be asynchronous.
The Fiber stores:
representing the load / unload transition currently in flight.[1]
When the epoch changes, _setEpoch() updates the target epoch. If inertia already exists at that point, no second lifecycle task is started concurrently.
For example, while B is unloading, serviceA reappears:
B UNLOADING
serviceA appears
At that point B's target epoch becomes an active epoch again, but _setEpoch() sees that inertia exists and won't directly run _reload() concurrently.
After the current _unload() finishes, the target epoch is checked again:
if target epoch is inactive
stay pending
else
reload with latest epoch
So the real process is:
This prevents the same plugin from executing load and unload at the same time.
The epoch here can be understood as the Fiber's version identifier for its "current dependency world."
Only a change in the target dependency environment requires the lifecycle to be coordinated again.
The Teardown Order of Effects
The Fiber maintains its own _disposables.
DisposableList.clear() returns the disposers in reverse registration order, so later-registered Effects enter teardown first.[1]
But the Fiber's _unload() uses Promise.all on these top-level disposers to perform async cleanup. In other words, the top-level Effects are invoked in reverse order, but their async completion order has no global serial guarantee.
The disposers collected inside a single ctx.effect(), on the other hand, are executed one by one in reverse order.
This is also a point the Cordis Primer in the DeepSeek Harness calls out specifically: if some resources have a strict teardown order between them, they should be placed in the same Effect, and that Effect should define the teardown order itself.[7]
The constraint is very practical. The framework owns the lifecycle; it shouldn't have to guess the business ordering between two independent resources.
When Context Recomputation Happens
Cordis has no background loop that continuously scans all plugins.
The core triggers come from structural changes:
Service registered
Service removed
Service implementation changes
plugin config changes
plugin restart / dispose
Taking a Service as an example:
ctx.provide()
↓
reflect.notify()
↓
affected fibers _checkImpl()
↓
_refresh()
↓
compute dependency epoch
↓
_setEpoch()
If the epoch doesn't change:
nothing happens in the lifecycle.
If it goes from INACTIVE to active:
PENDING → LOADING → ACTIVE
If it goes from active to INACTIVE, or the Provider identity changes:
ACTIVE → UNLOADING
When the Provider identity changes, the plugin loads again with the new epoch after the unload completes.
So the smallest unit of reconciliation is the Fiber, while the trigger is a change in the runtime environment it depends on.
Why DeepSeek Harness Needs This Layer
The current architecture of the DeepSeek Harness installs almost every major capability as a plugin:[6]
The official architecture documentation states it directly: model adapters, the Tool Registry, Session Logs and the Agent Loop itself are all plugins — there is no privileged kernel that every extension is required to patch.[6]
This means the runtime structure of the DeepSeek Harness is closer to:
The Agent Loop itself can depend on:
Session
LLM
Tools
SystemPrompt
When a certain Provider is replaced, the Plugin Fibers that depend on it can coordinate themselves again through the same epoch mechanism.
So what "Everything is a Plugin" needs isn't just a unified Loader. The really hard part is:
And that's exactly the runtime semantics Cordis provides.
Understanding It from Spring's Perspective
If you're familiar with Spring, a few concepts can be mapped to a limited extent:
The mapping is only a starting point and can't be taken as equivalence.
A typical Spring ApplicationContext is relatively stable in structure after refresh. Bean dependencies are mostly resolved at creation time; deleting a Bean at runtime and automatically invalidating every downstream Bean, then reactivating them once the dependency comes back, is not part of the conventional BeanFactory lifecycle model.
Cordis makes this kind of dynamic structural change a normal runtime path.
So its point isn't "one more DI container than Spring," but wiring DI together with lifecycle reconciliation.
The Cost of This Model
Dynamic composition isn't free.
First, plugin code needs to declare its dependencies correctly. Implicitly reading a Service breaks the Runtime's understanding of dependencies.
Second, all external registrations should go through Effect ownership. Writing state directly into global structures while bypassing the Context leaves behind resources that can't be revoked automatically.
Third, a plugin must be able to load / unload repeatedly. Initialization functions are no longer assumed to run only once.
Finally, dynamic dependencies raise the complexity of the state machine. PENDING / LOADING / ACTIVE / UNLOADING / FAILED, async cleanup, reentrancy and HMR all need rigorous handling.
So Cordis suits systems that genuinely need runtime composition. For a small agent with a fixed set of capabilities, abstracting every module as a dynamic plugin only adds to the cost of understanding.
The product goals of the DeepSeek Harness include plugin replacement, configuration composition, HMR, isolated Scopes and broad extension points, which make this complexity pay off clearly.
The next Demo will compress the core semantics of Cordis into a very small implementation: Service, Dependency, Effect, Fiber and reconcile. By actually running it, you can watch the A → B → C dependency chain go inactive and then reactivate layer by layer as A comes back.
References
[1] Cordis Fiber source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts
[2] Cordis Reflect / Service resolution: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts
[3] Cordis Context source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/context.ts
[4] Cordis Registry source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/registry.ts
[5] Cordis Service source: https://github.com/deepseek-ai/deepseek-harness/blob/master/vendor/cordis/src/service.ts
[6] DeepSeek Harness Architecture: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.md
[7] DeepSeek Harness Cordis Primer: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cordis-primer.md