Repository navigation
Usage Guide
This page helps integrators choose the right Nuplane adoption path and understand where baseline runtime usage ends and optional module behavior begins.
Nuplane's primary practical capability is installing NuGet packages into a running .NET application at runtime. The Usage Guide helps you wire that capability into your specific host shape.
The drop-folder workflow is the fastest way to see it work end-to-end:
- Configure a directory-backed feed watching a local
packagesfolder. - Enable automatic reconciliation so Nuplane reacts to file-system changes.
- Enable the optional loading module so assemblies are loaded into isolated contexts.
- Register observers so your host is notified when packages change.
- Read authoritative type lists from your catalog services.
From that baseline you can replace the folder with a remote NuGet feed, swap the file-drop trigger for a manifest or a CI/CD push, and build the host-side activation or routing logic that matches your use case.
| Scenario | Starting shape |
|---|---|
| Hot-reload plugins via file drop | Directory feed + watcher, loading module, IPlugin discovery in observers |
| Remote feed–driven feature delivery | NuGet v3 feed, polling reconciliation, loading module |
| SaaS tenant customization | Per-tenant directory feed or feed filter, isolated load contexts, tenant-scoped catalog reads |
| Workflow step registry | Directory or remote feed, loading module, type-finder over a known step interface |
| Metadata-only package-state tracking | Core runtime only, no loading module |
| Need | Applicability | Recommended path |
|---|---|---|
| Keep a runtime package set reconciled and queryable | Core |
Start with configuration-driven setup and query-first catalog reads |
| React to package changes in host code | Core |
Add host-owned observers or other host reactions after core setup |
| Load package assemblies into the current process | Optional Module |
Add the loading module and its read surfaces intentionally |
| Explore roadmap-stage governance or future operational patterns |
Phase-Based / Evolving
|
Read docs/roadmap.md and accepted specs rather than relying on the wiki alone |
-
Applicability:
Core
Choose this path when your host needs Nuplane to reconcile packages, preserve deterministic store state, and expose authoritative package inventory or operational state.
Typical shape:
- Configure feeds and reconciliation under the
Nuplanesection. - Let Nuplane reconcile desired vs actual package state.
- Read authoritative package state from the active catalog or admin read surfaces.
- Keep host decisions — cache invalidation, feature toggles, discovery, reload, and activation — in host code.
When a directory source, manifest, or custom desired-state source contributes the same package ID,
Nuplane resolves the overlap with DesiredStateOptions. A lower number has higher precedence; an
omitted source has the default priority int.MaxValue.
{
"Nuplane": {
"DesiredState": {
"SourcePriorities": {
"renewal-demo-updates": 0,
"renewal-demo-baseline": 10
}
}
}
}The source name is the PackageRequest.SourceName value. Directory-backed sources use the feed name
they were registered with (for example, renewal-demo-updates). A custom IDesiredPackageSource should
emit a stable source name in its requests and configure that same name here. Feed-resolution
priorities and CLR type names do not select the desired-state winner.
Use the configuration overload to override configured priorities in the builder callback after binding:
using Nuplane.Sources.Configuration;
services.AddNuplane(configuration.GetSection("Nuplane"), nuplane =>
{
nuplane.Services.Configure<DesiredStateOptions>(options =>
{
options.SetPriority("renewal-demo-updates", 0);
options.SetPriority("renewal-demo-baseline", 10);
});
});For code-only setup, omit the configuration argument and keep the same callback.
For one package ID, source priority is followed by the existing case-insensitive source-name and
version-range ordering, then deterministic feed, update-policy, and casing tie-breaks. Nuplane keeps
the winning request verbatim. This setting only chooses between overlapping desired requests; feed
resolution priorities, source admission and trust, package acquisition, fallback behavior, and
semantic version selection are unchanged. FeedResolution:FeedPriorities does not implicitly set
source priorities, and Nuplane does not compare semantic versions across sources. A higher-priority
exact 1.0.0 request therefore remains the winner over a lower-priority source's newer 1.1.0
request. With no priorities, existing case-insensitive source-name ordering remains the default.
-
Applicability:
Core -
Stability note:
Recently Changed
Current repository behavior is explicit about query-first reads:
- Use active package catalog reads when you need the authoritative active package set.
- Use operational or admin state reads when you need health or cycle outcome context.
- Treat observers as invalidation and logging signals, not as the system of record.
That split keeps hosts from rebuilding state from event history.
-
Applicability:
Core -
Stability note:
Recently Changed
Nuplane.NuplaneStore is a static, dependency-injection-free entry point for tooling that needs
the active package set — every active package's id, version, and install path — without a running
host, a DI container, or network access. It reports exactly the set a running host's
IActivePackageCatalog reports: active package descriptors whose version matches the state
file's recorded active version for that package id.
var activePackages = await NuplaneStore.ReadActivePackagesAsync("/var/lib/nuplane/.nuplane/store-state.json");
foreach (var package in activePackages)
{
Console.WriteLine($"{package.PackageId} {package.Version} -> {package.InstallPath}");
}An overload accepts a StoreRegistryOptions and resolves the effective state file path the same
way a running host does, via EffectiveStorePersistenceSettings.Resolve:
var activePackages = await NuplaneStore.ReadActivePackagesAsync(
new StoreRegistryOptions { StateFilePath = configuredPath });When a tool needs more than the active package set — the graph activation records that say which
packages were activated together, for instance — NuplaneStore.ReadStateAsync performs the same
read and returns the whole StoreStateRecord, and NuplaneStore.GetActivePackages(state) projects
the active packages from it. Reading once and projecting keeps every part of the answer on one
snapshot of the file; a missing state file yields StoreStateRecord.Empty().
Both overloads are strictly read-only — they open store-state.json for reading only, sharing the
file with a concurrent writer, and never create, rewrite, or migrate the file or its directory, so
it is safe to call them while a running host owns the file. A missing state file or a state file
with no active packages recorded both return an empty collection; a state file that exists but is
not valid JSON lets the underlying deserialization error propagate instead of being silently
treated as "no active packages." Passing options that resolve to in-memory persistence throws
InvalidOperationException, since there is no state file to read.
Because the host does not write store-state.json via a temp-file-and-move, a read that races a
concurrent write can observe one of two transient outcomes instead of a consistent snapshot: an
IOException (a sharing violation, if the write briefly holds an exclusive lock) or a
JsonException (torn or partial JSON content, if the read observes a write in progress). Both are
safe to retry; the reader itself does not retry on the caller's behalf.
-
Applicability:
Optional Module -
Stability note:
Recently Changed
Nuplane.Loading.NuplaneHostIntegratedLoader is the second half of the offline flow: it loads an
already-resolved active package set into assemblies exactly the way PackageLoadMode.HostIntegrated
loading does inside a running host — without a host, hosted services, reconciliation, a DI container,
or network access. Tooling that has to resolve a Nuplane host's module or provider assemblies the way
that host's own process resolves them uses it instead of re-implementing the loader.
LoadFromStateAsync is the entry point to reach for. Point it at the host's store-state.json and
it performs both steps — the same strictly read-only offline read NuplaneStore performs, then the
load:
var result = await NuplaneHostIntegratedLoader.LoadFromStateAsync(
"/var/lib/nuplane/.nuplane/store-state.json");
foreach (var package in result.Packages.Where(p => p.Status == PackageLoadStatus.Loaded))
{
Console.WriteLine($"{package.PackageId} {package.Version} -> {package.LoadMode}");
}
// Resolvable by name from the default context, through the same Default.Resolving hook a host installs.
var providerType = Type.GetType("Acme.Provider.SqlProvider, Acme.Provider");An overload accepts a StoreRegistryOptions and resolves the effective state file path the same way a
running host does, mirroring NuplaneStore.ReadStateAsync:
var result = await NuplaneHostIntegratedLoader.LoadFromStateAsync(
new StoreRegistryOptions { StateFilePath = configuredPath });LoadFromStateAsync is the only entry point with guaranteed grouping parity. It groups packages
into load graphs exactly as the host groups them — from the Active graph activation records the state
records, merging records that share a package, and falling back to the graph generation identity on
each active package descriptor only when the state holds no active graph record. Only the state carries
those records. The state is read once, so the package set and the graph records always come from one
snapshot of the file.
Each graph is loaded into one non-collectible context, so a package and its dependencies resolve each
other exactly as they do in the host. Because the loaded assemblies are published through the same
assembly-resolution catalog and the same AssemblyLoadContext.Default.Resolving hook, Type.GetType,
Assembly.Load, and a scan of AppDomain.CurrentDomain.GetAssemblies() all see them.
LoadActivePackagesAsync takes an IReadOnlyList<ActivePackage> instead, for callers that filter or
assemble the set themselves — for example after reading it with NuplaneStore.ReadActivePackagesAsync:
var activePackages = await NuplaneStore.ReadActivePackagesAsync(stateFilePath);
var result = await NuplaneHostIntegratedLoader.LoadActivePackagesAsync(
activePackages.Where(p => p.PackageRole == ActivePackageRole.Root).ToArray());An ActivePackage carries its graph generation identity but not the store's graph activation records,
so that entry point groups by graph generation identity alone. It matches the host whenever the state
holds no active graph record, and whenever every active graph record's node set matches the generations
the descriptors carry — the ordinary case after a single reconcile. It can differ when the store holds
several active graph records that share packages, because a record from an earlier reconcile survives
until a newer graph with the same root set replaces it: packages the host would load into one context
can then be split across two. Use LoadFromStateAsync when grouping parity with the host matters.
The load is irreversible for the lifetime of the process. Host-integrated assemblies go into non-collectible load contexts and the resolving hook is never removed, so nothing loaded this way can be unloaded, replaced, or hidden again. Use it from short-lived worker processes that exit after doing their work, not from a long-running process that expects to reload a package set. The entry point never writes: it does not touch the store, the state file, completion markers, or install directories.
Per-package problems are reported, not thrown. A graph whose install path is missing on disk, which
contains no loadable assembly, or which an activation gate refuses is reported as an ordinary load
failure for every package in it, in result.Packages (status Failed, with the reason in
Diagnostics) and in result.FailedByPackageId. A missing state file loads nothing and returns an
empty result, the same "nothing persisted yet" outcome the offline reader reports. Only a malformed
request throws: a null argument, a blank state file path, a package with a blank identifier, version,
or install path, duplicate package identifiers, or an unrecognized target framework override.
Options mirror what a host can configure:
var options = new HostIntegratedLoadOptions
{
// Resolve assets for the framework the host that installed the packages ran on,
// instead of the framework of this process. Defaults to the current process.
TargetFrameworkOverride = "net8.0",
LoggerFactory = loggerFactory
};
// The same refusal logic a host applies before activating a graph.
options.ActivationGates.Add(new SchemaVersionActivationGate());
// Supply the same shared assemblies the host configures, or the two processes can bind
// different copies of a shared assembly.
options.SharedAssemblies.Add(new SharedAssemblyIdentity("Acme.Contracts", "", 1));
var result = await NuplaneHostIntegratedLoader.LoadFromStateAsync(stateFilePath, options);A gate must not call back into NuplaneHostIntegratedLoader: a load holds a process-wide lock while
its gates run, so a nested call throws InvalidOperationException rather than waiting for a lock that
can never be released. Because gates fail closed, that throw refuses the graph the gate was evaluating and
is reported as an ordinary load failure naming the gate. Concurrent calls from unrelated flows are
safe and are serialized.
TargetFrameworkOverride overrides only the target framework. Runtime-identifier-specific assets —
both runtimes/<rid>/lib managed assets and native libraries — are still selected for the runtime
identifier of the current process, because they have to be loadable by it; there is no
runtime-identifier override.
Calling the entry point more than once in a process is safe: the resolving hook is installed exactly
once, and a package graph that is already loaded is reported from the load that loaded it rather than
loaded a second time. Asking for an already-loaded graph under a different TargetFrameworkOverride
throws InvalidOperationException instead, because the requested assets can never be the ones the
process already holds. A graph that another Nuplane composition in the same process has already loaded
host-integrated — a composed host, typically — is refused as an ordinary load failure instead of being
loaded into a second context, because two copies of the same assemblies in one process can silently
disagree about type identity. A host-free load is therefore for processes that do not compose a
Nuplane host; in a process that does, read the assemblies from that host's IPackageAssemblyCatalog
instead.
-
Applicability:
Core -
Stability note:
Recently Changed
The dependency graph resolver already skips acquiring a dependency when it finds a matching
package, at a satisfying version, in the host's own *.deps.json — the general rule, unchanged by
this section. Nuplane:HostProvidedPackages is for the narrower case: a dependency the host
guarantees it supplies that the resolver cannot confirm from *.deps.json alone (for example a
contract assembly the host loads outside the ordinary deps graph).
{
"Nuplane": {
"HostProvidedPackages": ["Acme.Contracts", "Acme.Plugins."]
}
}Each entry is either an exact package id ("Acme.Contracts") or a prefix ("Acme.Plugins.", a
trailing . marks it): a prefix matches every dependency package id that starts with it, including
the dot, case-insensitively, so "Acme.Plugins." matches Acme.Plugins.Sql but not
Acme.PluginsExtra or bare Acme.Plugins. Both forms are matched case-insensitively, and duplicate
entries are harmless. This list, like the *.deps.json rule it sits beside, is consulted only for
dependencies; it is never applied to a root package, explicit or contributed — a root is always
acquired however it is named.
Left unconfigured, only Nuplane's own contract package ids are treated as host-provided. Every other
package a dependency edge names — including your own product's ids — is acquired unless it is
already in the host's *.deps.json at a satisfying version. Declare your product's own
long-lived contracts here if a plugin's dependency on them should never be re-acquired.
A declared host-provided dependency is version-checked wherever the host's version is known. A
package declared here is never acquired. When the host's *.deps.json names its version, that
version is checked against the range the dependency requires:
- it satisfies the range: the dependency is skipped, as above;
- it does not: the package that declares the dependency is refused, together with every root
whose closure contains it. The refusal is recorded under the
host-version-unsatisfiedstage, with a message naming the dependent package, the dependency, the range it requires and the version the host provides. Every other root still applies. So a module built againstElsa.Workflows.Core [4.1.0, 5.0.0)on a host running4.0.0, withElsa.declared, is refused at reconciliation rather than loaded against the older assembly to fail later at a missing member.
A declared package the host's *.deps.json does not name has no version to compare against. It
is still treated as supplied — the declaration is the host's word for it — and each such dependency
is logged once per cycle as a warning (event 1035) naming the dependent package, the dependency and
the range it requires, so a dependency that needs a newer host than the one running is visible
rather than silent.
Undeclared packages are not checked. A package that is not declared here but that the host's
*.deps.json carries at a version outside the required range is not host-provided: it is acquired
like any other dependency, exactly as before. Under isolated loading the dependent package gets its
own private copy of every assembly not listed in Loading:SharedAssemblies, so a plugin that needs
a newer Newtonsoft.Json than its host still works. Only a declaration says the host supplies a
package, so only a declared package is refused when the host's version cannot satisfy it.
The host's versions come from the deps files the .NET host loaded for the process that runs
reconciliation — the application's own *.deps.json and the shared frameworks' (which list only
their runtime packs), as the runtime reports them in APP_CONTEXT_DEPS_FILES. That includes a deps
file supplied with --depsfile, so a host-free restore (NuplaneRestore) run in a separate tool
process launched as dotnet exec --runtimeconfig <host>.runtimeconfig.json --depsfile <host>.deps.json <tool>.dll checks against the host's versions, not the tool's. Only when the
runtime reports no loaded deps files does Nuplane fall back to scanning the application base
directory for *.deps.json. A single-file bundle's own deps file is embedded in the bundle and not
reported, so neither source sees the application's packages there — its declared host-provided
dependencies are trusted and logged as unverified. Which source was used is logged at Debug
(event 1036).
This is not Loading:SharedAssemblies. The two configure different stages and answer different
questions. HostProvidedPackages decides, before a package is ever on disk, which dependencies
the resolver skips acquiring at all. Loading:SharedAssemblies (the options.SharedAssemblies list
just above) decides, after a package has been acquired and is being loaded, which of its
assemblies resolve from the host's own load context instead of a package-specific one, so that a
type from that assembly is assignable across package boundaries. See Sharing assemblies with the
host. A package id can belong on one
list, the other, both, or neither — being host-provided does not make an assembly shared, and a
shared assembly need not be host-provided (it may still be acquired as an ordinary dependency and
simply loaded from the host's context).
Earlier versions matched a fixed, product-specific allowlist in code (CShells.*, thirteen Elsa.*
ids, and the Microsoft.Extensions. prefix) in addition to Nuplane's own contract ids. That
allowlist is gone; only Nuplane's own ids are host-provided by default now, and a dependency on any
of the removed ids is acquired like any other unless it also appears in the host's *.deps.json at
a satisfying version. A host that relied on the fixed list restores the exact previous skip
decisions by configuring it explicitly:
{
"Nuplane": {
"HostProvidedPackages": [
"CShells.Abstractions",
"CShells.AspNetCore.Abstractions",
"CShells.FastEndpoints.Abstractions",
"Nuplane.Abstractions",
"Nuplane.Loading.Abstractions",
"Elsa.Api.Common",
"Elsa.Caching",
"Elsa.Common",
"Elsa.Expressions",
"Elsa.Features",
"Elsa.KeyValues",
"Elsa.Mediator",
"Elsa.Resilience",
"Elsa.Resilience.Core",
"Elsa.Tenants",
"Elsa.Workflows.Core",
"Elsa.Workflows.Management",
"Elsa.Workflows.Runtime",
"Microsoft.Extensions."
]
}
}-
Applicability:
Core -
Stability note:
Recently Changed
Nuplane.NuplaneRestore is the writing half of the host-free trio: NuplaneStore reads a store,
NuplaneHostIntegratedLoader loads what a store records, and NuplaneRestore populates a store from
a host's own configuration without starting that host. Out-of-process tooling uses it when a
deployment step, a CLI, or a container build has to get a host's packages onto disk before the host
ever runs.
var result = await NuplaneRestore.RestoreAsync(configuration, new NuplaneRestoreOptions
{
BasePath = hostContentRoot,
InstallRoot = "/srv/app/.nuplane/packages",
StateFilePath = "/srv/app/.nuplane/store-state.json",
LoggerFactory = loggerFactory,
// Directory feeds belong to Nuplane.Sources.Directory, so the caller adds them. The second
// argument is the already-resolved Nuplane configuration — use it, not a captured `configuration`,
// or the module registration helper finds nothing when `configuration` is the host's root. A
// relative `DirectoryPath` in that configuration resolves against `BasePath` above, not against
// this process's own current directory — AddDirectoryFeedsFromConfiguration reads it from the
// same NuplaneBuilder this callback receives, so nothing here has to pass it along.
ConfigureBuilder = (builder, nuplane) => builder.AddDirectoryFeedsFromConfiguration(nuplane)
});
Console.WriteLine($"restored into {result.InstallRoot}; state at {result.StateFilePath}");
foreach (var package in result.ActivePackages)
{
Console.WriteLine($"{package.PackageId} {package.Version} -> {package.InstallPath}");
}configuration may be the host's configuration root or its own Nuplane section; RestoreAsync
resolves the Nuplane section itself when the root is given, which is why ConfigureBuilder
receives the resolved section as its own argument rather than requiring the caller to pre-resolve it.
NuplaneHostIntegratedLoader.LoadFromStateAsync(result.StateFilePath) then loads exactly what the
restore installed, so the two entry points compose into "populate, then use" inside one tool.
One cycle, no host, nothing loaded. The entry point composes a throwaway service provider, runs
one manual reconciliation cycle through IReconciliationService directly, and disposes everything.
No hosted service starts, nothing polls, no directory is watched, and the loading module's
auto-loading observer is never registered — so no assembly from a restored package enters the calling
process. The queue-and-wait trigger ingress a host uses is deliberately not used: only the dispatcher
hosted service completes it, so without a started host it would never return.
Write set. A restore writes only under the resolved install root (extracted packages and its
.tmp staging directory), the resolved state file, and the store lock file beside that state file.
It never deletes an installed package: cleanup during a cycle records decisions and removes nothing
from disk, so a package that leaves the desired set stays extracted.
Path defaults and overrides. This is where a hand-rolled composition silently goes wrong. A
running host resolves FeedResolution:PackageInstallRoot and the state file against its own
AppContext.BaseDirectory, and relative configured values against its current directory. For a
restoring tool both of those name the tool, so NuplaneRestore never falls back to them:
| Path | Resolution order |
|---|---|
| Install root |
NuplaneRestoreOptions.InstallRoot (must be absolute) → absolute Nuplane:FeedResolution:PackageInstallRoot → relative configured value against BasePath → BasePath/.nuplane/packages → refused
|
| State file |
NuplaneRestoreOptions.StateFilePath (must be absolute) → absolute Nuplane:StoreRegistry:StateFilePath, or the Nuplane:Setup:StateFilePath shorthand → relative configured value against BasePath → BasePath/.nuplane/store-state.json → refused
|
| Package lock file |
NuplaneRestoreOptions.LockFilePath (must be absolute) → absolute Nuplane:LockFile:Path → relative value against BasePath, otherwise against the resolved state file's directory |
A directory feed's own DirectoryPath is not one of the three paths above — it belongs to
Nuplane.Sources.Directory, not the core package, so it is resolved separately by that module
against the same BasePath, and never refuses: a relative DirectoryPath falls back to resolving
against the current directory instead, exactly like a running host that has not set a base of its
own. See Directory feeds as an offline package source
for that resolution, including how a host sets one, in both the host and the host-free case.
"Refused" is an InvalidOperationException naming the path and telling you to set BasePath or the
matching override. A restore that quietly populates the wrong directory reports success and leaves
the host empty, which is worse than a loud failure. The package lock file is the one path that never
refuses, because its configured default is the bare relative name nuplane.lock.json that no
operator typed; it anchors to the store instead of to the restoring process. A non-absolute override
throws ArgumentException, and a configuration selecting UseInMemoryStore throws
InvalidOperationException, because a restore into a store that persists nothing would report
success and write nothing.
The keys in the table above are relative to the Nuplane section: pass either the host's
configuration root — the one that nests them under a Nuplane section, beside the host's other
sections — or that Nuplane section itself, to RestoreAsync and DescribeDesiredAsync; whichever
is given, its Nuplane child section is used when one exists. A configuration that, once composed,
names no feed and no desired package source at all is refused the same way, rather than reported as
a restore that quietly did nothing.
result.StateFilePath and result.InstallRoot report the paths the runtime itself derived, so a
caller can prove which store it populated instead of inferring it from configuration.
Failures are reported, not thrown. A degraded cycle sets IsDegraded; packages that could not be
applied are listed in FailedPackages, and Refusals says why each one failed; a feed whose
Credentials reference could not be resolved is named in CredentialRefusedFeeds. Such a feed is dropped from resolution before the first network
call rather than contacted without credentials and rejected by the feed — a package that could only
have come from it is also reported as a failed package. A feed whose reference does resolve is
restored from like any other feed and is not named there; see Feed credentials,
whose built-in env provider needs no registration and therefore works in a host-free tool exactly
as it does in a host. Only a malformed request throws: a null configuration, a non-absolute override,
a path nothing pins, in-memory persistence, or configuration that fails Nuplane's own options
validation — which now includes a Credentials value that is not a well-formed secrets:// reference.
Why a package failed. FailedPackages is a list of ids. Refusals is the list beside it that
carries, for each of those packages, the stage the cycle recorded its failure under and the message
Nuplane wrote for it — the same FailureRecord the store's LastFailureById holds, joined on this
cycle's failed ids and its correlation id so that a failure an earlier cycle recorded for a package
this one installed is never reported as if it had just happened. A caller that used to read the
state file back to classify a failure reads this instead:
foreach (var refusal in result.Refusals)
{
var kind = refusal.Stage.StartsWith("capability-", StringComparison.Ordinal)
? "a decision this host has not made"
: "a package that could not be acquired";
Console.WriteLine($"{refusal.PackageId} ({refusal.Stage}, {kind}): {refusal.Message}");
}The stages a caller can expect, and what each says:
| Stage | Meaning |
|---|---|
capability-unselected |
A package declares a capability, no selection is configured for it, and no explicit root satisfies any declared option. The message names the capability, every declared option, and the Nuplane:Capabilities:<capability> key that selects one. |
capability-unknown-option |
The configured selection names an option the capability does not declare. |
capability-unpinned |
The selected option's effective version range is not a single pinned version and RequirePinnedVersions is set; the contribution is also listed in UnpinnedRequests. |
capability-conflict |
Two declarations of the same capability disagree, or an explicit root cannot satisfy a declared option. |
capability-unresolved |
The selected option's package could not be acquired from any eligible feed; recorded against every package that declared the capability. |
capability-metadata-invalid |
The package's schema-2 nuplane.json is invalid. |
capability-contribution-limit |
Contribution rounds did not reach a fixpoint within the bound. |
resolve-feed-unavailable, resolve-no-eligible-feed, resolve-graph-conflict, resolve
|
The package itself could not be resolved: the feed could not be reached, no configured feed serves it, or its dependency graph could not be unified. |
lock |
The lock file refused the package; the message is the lock outcome's reason code. |
host-version-unsatisfied |
The package, or a package in its closure, depends on a package declared in Nuplane:HostProvidedPackages that the host carries at a version outside the range the dependency requires. The message names the dependent package, the dependency, the required range and the host's version. See Packages the host already provides. |
Every capability-* stage means the package is on a feed and what is missing is a decision the
host has not made; every resolve-* stage means the package could not be fetched. Match on the
prefix where that is the distinction that matters, because a later Nuplane may add a stage beside
these. A restore that was skipped recorded nothing, so Refusals is empty there, as it is for a
package the cycle failed without recording a failure of its own — a graph node that failed only
because a sibling in its graph did.
Pre-flight. DescribeDesiredAsync answers "what would this restore ask for, and where would it
put it?" without doing any of it:
var description = await NuplaneRestore.DescribeDesiredAsync(nuplane, options);
foreach (var request in description.Requests)
{
Console.WriteLine($"{request.PackageId} {request.VersionRange} from {request.FeedName} " +
(request.IsPinned ? $"(pinned to {request.PinnedVersion})" : "(not pinned)"));
}It writes nothing — no install root, no state file, no lock file — and contacts no remote feed. It
touches local disk only where a desired source already lives: a directory-backed feed enumerates its
own .nupkg files, and a convergence manifest source reads its manifest. A remote feed's requests
come from its configured include patterns alone, so no service index, version list, or package is
fetched. A source that throws is reported in SourceErrors rather than propagating, so an empty
request list with an error in it means "could not tell", not "nothing is desired".
IsPinned is the answer that cannot be computed outside the package, because the include-pattern
parser and the version-request classifier that decide it are internal. Package [1.2.3] and a bare
1.2.3 are pinned; a bare package identifier, a range such as [1.0.0,2.0.0), and a floating 1.*
are not. A wildcard include pattern against a remote feed contributes no request at all, because
there is no catalog to expand it against without contacting the feed.
Set NuplaneRestoreOptions.RequirePinnedVersions when a restore has to be reproducible. Any unpinned
request then makes the restore do nothing: it returns Skipped with
NuplaneRestoreSkipReason.UnpinnedRequests and lists the offenders in UnpinnedRequests, before
anything is resolved, downloaded, installed, or written.
The pinned rule and contributed roots. A desired request's pinned-ness is answerable before the
cycle, which is what makes the refusal above a skip. A root contributed by a
selected capability is not: the package that declares it must be
acquired before its nuplane.json can be read. RequirePinnedVersions therefore travels into the
cycle, where each contribution's effective range — the host's Version override, else the declared
option version — is classified by the same rule, and an unpinned one is refused before the
contributed package is resolved or downloaded. So a pinned-only restore never fetches an unpinned
package either way, but an unpinned contribution is not a skip: the roots were acquired, so the
result has Skipped = false, IsDegraded = true, the declaring package in FailedPackages with a
capability-unpinned entry in Refusals, and the offending contribution in the same UnpinnedRequests list, with the
capability:<capability>=<option> source name that says which decision produced it. A declaration
that pins its options to exact versions — which is what a package author should write — restores
without any of this. DescribeDesiredAsync cannot list contributions at all, because it resolves no
package and contacts no feed; it reports the host's configured selections in CapabilitySelections
instead, and contributed roots appear only in a cycle.
-
Applicability:
Core -
Stability note:
Recently Changed
Every reconciliation cycle — a running host's and a host-free restore's alike — now holds an exclusive
lock on the store it writes, for the whole cycle. A cycle is a read-modify-write of
store-state.json, and the file is rewritten with a truncating exclusive File.Create, so two
processes reconciling one store could tear it; EnableSingleFlight only ever serialized cycles inside
a single reconciliation service. The lock is an exclusive handle on a zero-length file beside the
state file (store-state.json.lock) — nothing in Nuplane enumerates the state directory, and every
install-directory enumeration filters by extension, so it cannot be mistaken for a package, a
completion marker, or a state artefact.
A host's last-known-good startup recovery holds the same lock around its own read-then-republish of
the state file, for the same reason: recovery can transitively write the store too — republishing the
last-known-good packages as reconciled can drive a load failure back into a failure record — and it
must not act on a state file a concurrent cycle or restore is mid-rewrite on. Recovery does not skip
the instant the lock is unavailable, though: a periodic reconciliation cycle that skips simply tries
again on its next poll, but startup recovery has no next poll, so treating an ordinary, short-lived
race (a concurrent NuplaneRestore run, a second host process starting at the same time) as an
immediate failure would turn a race into an outage. Recovery instead polls the lock — every 250 ms —
for up to Nuplane:Reconciliation:StartupRecoveryStoreLockTimeout (default 30 seconds; Zero means
recovery does not wait either), honouring cancellation throughout. Only once that timeout elapses does
recovery give up, doing nothing and reporting the skip on its result — the same
last-known-good-store-lock-unavailable shape as ReconciliationSkipReason.StoreLockUnavailable on a
reconciliation cycle. StartupFailurePolicy.UseLastKnownGood then fails startup for that reason,
because recovery genuinely could not establish a package set.
It is on by default, behind Nuplane:Reconciliation:EnableStoreLock. The failure it prevents is
silent corruption that outlives the process; the behaviour it introduces is a reported, retryable
skip. A store with a single writer never contends, so its behaviour is unchanged.
Acquisition never waits — except for startup recovery, above. A reconciliation cycle that cannot take
the lock returns immediately with Skipped and ReconciliationSkipReason.StoreLockUnavailable, logs
a warning, and does nothing at all — no read, no resolve, no write. NuplaneRestore surfaces the same
outcome as NuplaneRestoreSkipReason.StoreLockUnavailable and leaves ActivePackages empty, because
no read-back is attempted while whoever holds the store may be rewriting its state file. Callers
retry.
Retrying is the caller's job. A host with automatic reconciliation enabled retries on its next poll. A host whose startup cycle is skipped this way starts against whatever the store already records and — with automatic reconciliation off, which is the default — does not reconcile again on its own; startup is not failed for contention, because that would break rolling restarts of replicas sharing one store. The warning naming the lock file is the signal to watch for.
Two cases deliberately do not refuse. An in-memory store has no file to lock and behaves exactly as
before. A store whose lock file cannot be created or opened at all — a read-only state directory, or
a lock file this process may not write — is reconciled unprotected with a warning rather than refused,
so a deployment that works today keeps working. Set EnableStoreLock to false to restore the
pre-lock behaviour exactly.
-
Applicability:
Core
Choose configuration-first setup when you want the host to declare:
- feeds and include patterns;
- automatic reconciliation and poll interval;
- state-file persistence;
- optional loading settings when the loading module is installed.
The sample appsettings.json is the best concrete repository anchor for this path.
Prefer keyed feed setup under Nuplane:Setup:Feeds, where each feed key is the feed name.
This avoids positional array merging when appsettings.json, environment variables, and mounted
configuration files are layered. Feed object order is not semantic; use DesiredState:SourcePriorities
for overlapping desired requests and FeedResolution:FeedPriorities for feed resolution candidates.
When the same setting is expressed in both layers, the more specific runtime option section wins
over the Nuplane:Setup shorthand. An explicitly present
Reconciliation:EnableAutomaticReconciliation decides automatic reconciliation in both directions,
so false there disables polling even when Setup:AutomaticReconciliation is true; the same
applies to Reconciliation:PollInterval over Setup:PollInterval.
Store persistence follows the same rule: StoreRegistry:StateFilePath and
StoreRegistry:UseInMemoryStore decide over Setup:StateFilePath and Setup:UseInMemoryStore, so
StoreRegistry:UseInMemoryStore: false keeps state persisted even when the shorthand asks for an
in-memory store. Because the two persistence settings are mutually exclusive, an explicit choice in
StoreRegistry also suppresses the opposing shorthand instead of combining into a rejected
configuration. Builder calls run last, so WithStateFile(...) and UseInMemoryStore() in the
AddNuplane callback still override both configuration layers.
-
Applicability:
Core -
Stability note:
Recently Changed
A package can declare a capability: a named choice of root package it needs at run time but does
not depend on. The concrete case is a persistence module that binds one of several database engines
reflectively, so no dependency edge names the engine and the dependency walk never acquires it. The
package declares the options in its nuplane.json; the
host decides which one, under its own Nuplane section:
{
"Nuplane": {
"Capabilities": {
"ef-provider": "PostgreSql"
}
}
}The environment-variable form is Nuplane__Capabilities__ef-provider=PostgreSql, and the code form
is builder.SelectCapability("ef-provider", "PostgreSql") in the AddNuplane callback. Builder
calls run last, so a SelectCapability call overrides a configured selection for the same
capability.
The object form says more about the same selection:
{
"Nuplane": {
"Capabilities": {
"ef-provider": { "Option": "PostgreSql", "Version": "[10.0.0]", "Feed": "nuget.org" }
}
}
}| Field | Meaning |
|---|---|
Option |
The option name, matched case-insensitively against the declared options. A host that runs two engines on purpose selects both, as "PostgreSql,Sqlite" or as a JSON array. |
Version |
Replaces the version range the declaring package declared for the selected option, for a host that needs a different patch of it. |
Feed |
The feed the contributed root prefers. Package metadata cannot name a feed — which feed supplies a package is a host fact — so this is the only place one can be named. |
What a selection does. In every reconciliation cycle, after the desired roots are resolved and
their dependency closure expanded, Nuplane reads nuplane.json from each resolved package and turns
every selected option into an additional root package request whose source name is
capability:<capability>=<option>. From there it is an ordinary root: resolved from a trusted feed
under the same retry policy, evaluated against the lock file, applied in the same transaction,
recorded in store-state.json with PackageRole = Root and that source name, reported by
NuplaneStore.ReadActivePackagesAsync, and loaded and discoverable exactly like a root a desired
source asked for. Nothing bypasses trust, integrity, or the lock file. A dry run includes it,
because a dry-run plan is computed from the cycle's own resolved set. An option package that itself
declares a capability is honoured too: contribution rounds repeat until a round adds nothing,
bounded at eight rounds. Changing the selection changes the graph, so the next cycle reconciles the
previous option out of the desired set and the new one in.
Naming the package by hand still works, and wins. When one of the option packages is already an
explicit desired root — a directory-feed drop, an include pattern, a convergence manifest — the
capability is satisfied by that root, nothing is contributed, and the only difference from before
this feature existed is one Information log line. A selection whose option package is already an
explicit root contributes nothing either: the explicit root wins. When that explicit root's version
cannot satisfy the option's declared range, the declaring package is refused
(capability-conflict), naming both requests, rather than silently bound against a version it
cannot use — and the explicit root the operator asked for is still applied.
No selection is a refusal, never a guess. A package that declares a capability the host has not
selected, and none of whose option packages is a desired root, fails resolution with stage
capability-unselected and a message naming the capability, every declared option, and the
configuration key. The cycle is degraded — Reconciliation:StartupFailurePolicy then decides what
that means for host startup — and no option package is installed. Nuplane never picks an option for
you, and package metadata has no default: a silently chosen engine is the failure that surfaces
much later as a reflection error at load time. The other refusals read the same way, recorded
against the declaring package and visible in the store's failure record:
capability-unknown-option (the selection names an option the package does not declare),
capability-unpinned (see the pinned rule),
capability-unresolved (no trusted feed has the option package),
capability-metadata-invalid (a schema-2 nuplane.json that does not validate), and
capability-contribution-limit (contribution rounds outgrew the bound). A selection that matches no
declaring package in the cycle breaks nothing, so nothing fails; it is logged as a warning, which is
how a typo in the key becomes visible.
A capability's injected root is acquired like any other package — it is never subject to
Nuplane:HostProvidedPackages (see Packages the host already provides),
which is consulted only for dependencies.
A directory feed declared with DirectoryPath both contributes desired roots and resolves packages,
so pre-populating it with the full dependency closure removes the boot-time network dependency —
useful when baking packages into a container image.
Where a relative DirectoryPath resolves from. Both AddDirectoryFeed and
AddDirectoryFeedsFromConfiguration resolve it against NuplaneBuilder.BasePath when something has
set one, and against the process's own current directory otherwise. Nothing sets it by default, so a
host that composes Nuplane directly and never calls UseBasePath keeps resolving it against its own
current directory exactly as it always has — the right answer only when the host is guaranteed to
start from its own directory. A host started some other way — as a service from /, for example —
should pass its own content root instead:
var builder = WebApplication.CreateBuilder(args);
var nuplaneConfiguration = builder.Configuration.GetSection("Nuplane");
builder.Services.AddNuplane(nuplaneConfiguration, nuplane =>
{
nuplane.UseBasePath(builder.Environment.ContentRootPath);
nuplane.AddDirectoryFeedsFromConfiguration(nuplaneConfiguration);
});UseBasePath takes only an absolute path — a relative one throws ArgumentException at the point of
the call, not later when a feed tries to resolve against it — and Nuplane's core package never reads
IHostEnvironment itself, so this stays the host's own line to write rather than a hosting dependency
Nuplane adds on the host's behalf.
A host-free restore through NuplaneRestore sets the same
NuplaneBuilder.BasePath from NuplaneRestoreOptions.BasePath, the base its other paths already
anchor to, before its ConfigureBuilder callback runs — so that section's example needs nothing
beyond what it already shows for BasePath to apply to directory feeds too. Because the restore sets
it first, a ConfigureBuilder callback that calls UseBasePath itself overrides the restore's base
rather than being overridden by it; the last call always wins. An already-absolute DirectoryPath is
unaffected by any of this either way.
Resolution reads the directory itself: package identifiers are matched case-insensitively and
versions are matched in normalized form. A .nupkg written by dotnet restore under a lower-cased
file name therefore still satisfies a dependency that declares the canonical identifier, including
on case-sensitive file systems such as those inside Linux containers.
The directory is only ever read. Packages resolved from it are extracted under
Nuplane:FeedResolution:PackageInstallRoot — at
{PackageInstallRoot}/{feedName}/{packageId}/{version} — the same layout remote feeds use, so a
directory feed can be baked into a container image or mounted read-only:
docker run --read-only \
-v "$(pwd)/packages:/app/packages:ro" \
-v nuplane-data:/var/lib/nuplane \
-e Nuplane__FeedResolution__PackageInstallRoot=/var/lib/nuplane/packages \
your-registry/nuplane-host:latestOnly PackageInstallRoot (and the store state file path) has to be writable. Earlier versions
extracted into a .installed/ subdirectory of the feed directory; hosts upgrading from those
versions can delete that directory, and packages are re-extracted once under the install root.
Hosts can add a named package-store root locator with NuplaneBuilder.AddPackageStoreRoot, supplying
a label and a root locator. The default label is reserved for the root selected by the final
Nuplane:FeedResolution:PackageInstallRoot option (or Nuplane's existing application-base
fallback). Other labels are unique without regard to case. A relative locator is anchored to the
callback's final BasePath, or to the current directory when no base is supplied, using
Path.GetFullPath(locator, basePath). On Windows, with C:\app as that base, C:packages resolves
to C:\app\packages, \packages to C:\packages, and D:packages to D:\packages without
using an ambient per-drive current directory. A fully qualified locator stays absolute.
This records configured locations for store coordination. Registration does
not inspect or create a directory, enroll it, or by itself authorize a maintenance operation.
Nuplane:PackageStoreMaintenance:RootLabel names the default root for maintenance lookup and
defaults to default. A custom label replaces that maintenance alias; it does not change the
install path or the catalog's reserved default entry. Additional configured roots retain their
labels. Blank or padded labels and collisions with additional labels fail options validation at
startup. This lookup configuration does not enroll roots or enable inspection/pruning APIs.
-
Applicability:
Core -
Stability note:
Recently Changed
A private feed is configured with a reference to its secret, never with the secret:
{
"Nuplane": {
"Setup": {
"Feeds": {
"private-feed": {
"ServiceIndex": "https://packages.example.com/v3/index.json",
"Credentials": "secrets://env/MY_FEED_TOKEN",
"IncludePatterns": [ "Acme.Plugins.*" ]
}
}
}
}
}Reference syntax. secrets://<provider>/<name>. The provider segment selects a registered
provider and is matched case-insensitively; everything after the first slash is the name, passed to
that provider unchanged — so a provider addressing secrets by path takes
secrets://vault/apps/nuplane/feed-token without further syntax. A Credentials value that is not
of this shape fails options validation at startup, and the rejected value is never echoed in the
error, because a host that pasted the token itself into Credentials is exactly the case that rule
catches. Credentials remain forbidden on file:// feeds, and every credentialed feed still has to be
an HTTPS service index.
Built-in provider: env. AddNuplane registers it, so secrets://env/MY_FEED_TOKEN reads that
process environment variable with no further setup — in a host, and equally in a host-free
NuplaneRestore tool. A variable that is unset or empty is not an error; it refuses the feed (below).
Secret shape. Whatever the provider returns is either user:password — split at the first colon,
so a password may contain colons — or a bare token, which Nuplane sends as the password under the
fixed placeholder user name nuplane, the form Azure Artifacts, Feedz and other token-issuing feeds
accept. A value whose colon leaves either half empty is refused rather than sent half-formed. Both
the version enumeration and the package download authenticate, each with credentials attached to that
one feed; Nuplane never installs a process-global NuGet credential provider, so one feed's secret is
never offered to another.
Adding a provider. Implement ISecretReferenceProvider and register it; the resolver is the only
composition point, and nothing else in Nuplane knows how a secret is stored:
public sealed class VaultSecretReferenceProvider(IVaultClient client) : ISecretReferenceProvider
{
public string Scheme => "vault";
public async ValueTask<string?> ResolveAsync(string name, CancellationToken cancellationToken) =>
await client.TryReadAsync(name, cancellationToken); // null when there is no such secret
}
services.AddSingleton<ISecretReferenceProvider, VaultSecretReferenceProvider>();Return null (or an empty string) when the provider holds nothing under that name: that is an
ordinary answer that refuses the feed, not a failure. Throw only when the lookup itself failed, and
never with the secret in the message.
Replacing the built-in env provider. Remove its descriptor, then register your own for the same
name:
services.Remove(services.Single(descriptor =>
descriptor.ServiceType == typeof(ISecretReferenceProvider)
&& descriptor.ImplementationType == typeof(EnvironmentSecretReferenceProvider)));
services.AddSingleton<ISecretReferenceProvider, MyEnvProvider>();services.RemoveAll<ISecretReferenceProvider>() removes every provider, built-in included. Adding a
second provider that claims env without removing the first is refused when the resolver is built,
rather than silently resolved in favour of one of them: a replacement that did not take effect must
not look like one that did.
When a reference cannot be resolved — no registered provider claims its provider segment, the
provider holds no value, or reading it failed — the feed is refused by name. It is not contacted at
all: not for version enumeration, not for a download, and not even to serve a package an earlier,
authenticated run already installed from it. A host-free restore reports the feed in
NuplaneRestoreResult.CredentialRefusedFeeds and drops it before its first network call; a running
host fails the packages that could only have come from it, naming the feed in the failure. With no
provider able to resolve anything, behaviour is exactly what it was before credentials could be
resolved at all: the feed is refused, and every other feed is unaffected.
What is logged, and what is not. The reference is configuration and may appear in logs; the
secret never does. Resolved secrets are wrapped so that logging, interpolating or including one in an
exception prints ***, and refusal and failure messages name the feed only — never the value, the
reference, or the provider. Nothing resolved is written to the store, and a resolved secret is cached
for the duration of one reconciliation cycle and dropped with it, so one feed's secret is read once
per cycle and no longer than that.
-
Applicability:
Core
Choose code-first setup when you want to:
- register observers or host reactions;
- compose admin/query surfaces in a particular host shape;
- use module-owned registration helpers directly.
The sample Program.cs is the best concrete repository anchor for this path.
-
Applicability:
Core - Best for hosts that only need package-state awareness, deterministic storage, and package reconciliation.
- Works without installing the loading module.
- Keeps the runtime boundary small and host-neutral.
-
Applicability:
Optional Module - Use this when your host explicitly wants Nuplane-managed package assembly loading and load-state catalog reads.
- The loading module is opt-in, and it does not change the rule that the host owns plugin or activation semantics.
- If the loading module is absent or disabled, that is still a valid Nuplane integration.
NuGet package IDs and assembly names can differ. A library package can supply several runtime
assemblies without a DLL named after the package (for example, PdfPig supplies
UglyToad.PdfPig.*.dll). Nuplane admits the compatible runtime asset set in both loading modes
and resolves assemblies in a dependency graph through the same load context, preserving CLR type
identity across packages. No forwarding DLL or renamed package contents are required.
Assembly discovery remains deterministic: catalog candidates use path ordering and include only the selected framework/RID assets. A single assembly, or a unique assembly whose filename matches the package ID, retains its primary-assembly metadata. A multi-assembly library with neither has no primary candidate; all of its selected assemblies are available for discovery. The host still chooses its feature/module entry points. Missing dependencies fail when their types or code are used, incompatible target frameworks remain load failures, and conflicting assembly identities are refused rather than resolved by picking a DLL from the graph.
Satellite resource assemblies are distinguished by simple name and culture in both loading modes,
including host-visible by-name resolution. A package can carry the same .resources.dll name for
multiple cultures; ResourceManager can resolve each culture and apply its normal parent-culture
and neutral-resource fallback. Conflicting identities within the same name and culture remain
load failures.
- Use
Collectiblefor isolated plugin discovery, scan-only packages, and scenarios where unloadability matters. It is the default to preserve existing loading behavior. - Use
HostIntegratedfor packages that contribute application-lifetime framework types such as DI registrations, endpoints, hosted services, options, validators, or database migrations. Host-integrated package assemblies may remain loaded for the process lifetime. - Configure shared assemblies separately from load mode. Shared assemblies preserve contract/type identity; load mode controls package lifetime and whether active package assemblies are made visible to framework by-name resolution.
- Keep
LoadModeSelectionPolicyatAutomaticwhen you want Nuplane to evaluate package-declared metadata before falling back toDefaultLoadMode. - Use package-specific
PackageLoadModesoverrides when the application must force a package toHostIntegratedorCollectible; these overrides win over package metadata for the same package. - Use
ExplicitOnlyonly when you want to ignore package metadata and rely onDefaultLoadModeplus explicit package overrides.
Package-authored metadata lives at package-root nuplane.json:
{
"schemaVersion": 1,
"loading": {
"loadMode": "HostIntegrated",
"scope": "DependencyClosure",
"reason": "Uses framework type resolution and runtime scheduler integration."
}
}HostIntegrated metadata is treated as a requirement and promotes the loadable dependency closure. Collectible metadata is only a preference; it never forces a graph down from a host-configured HostIntegrated default or another host-integrated requirement.
-
Applicability:
Optional Module
Every package graph loads into a context of its own, so a package that carries an assembly gets its own copy of it, and a type from that copy is not the host's type. List an assembly under Loading:SharedAssemblies when package code and host code must agree on its types — typically a contracts assembly the host itself references:
{
"Nuplane": {
"Loading": {
"SharedAssemblies": [
{ "Name": "Acme.Contracts", "PublicKeyToken": null, "MajorVersion": 4 },
{ "Name": "Newtonsoft.Json", "PublicKeyToken": "30ad4fe6b2a6aeed", "MajorVersion": 13 }
]
}
}
}The code equivalent is loading.SharedAssembly("Acme.Contracts", null, 4) on the AutoloadPackages builder, or a SharedAssemblyIdentity added to LoadingOptions.SharedAssemblies (or to HostIntegratedLoadOptions.SharedAssemblies for a host-free load).
An entry matches an assembly by simple name (case-insensitively), public key token and major version:
-
PublicKeyTokenis the 16-hex-character token of a strong-named assembly. For an unsigned assembly, set it tonullor"", or leave it out: all three mean unsigned and are the same identity. Any other value that is not 16 hex characters fails startup validation. -
NameandMajorVersionare required. An entry without either, or whoseMajorVersionis not a number, fails startup validation with an error naming the entry's configuration path, for exampleNuplane:Loading:SharedAssemblies:1. The configuration binder would otherwise leave such an entry out without a word, and the host would run as if it had never declared it. Earlier versions did exactly that, and also left out every entry whosePublicKeyTokenwasnullor absent.
A matched assembly resolves to the host's copy in every load mode, however it is reached:
- When package code binds it by name, the package's load context resolves it from the host's default context.
- A package that carries its own copy of it — beside its own assembly, or as the shared assembly's own package acquired as a dependency — never has that copy loaded.
HostIntegratedloading neither loads it nor publishes it for framework by-name resolution, so two packages that both carry it do not conflict.IPackageAssemblyCatalogleaves it out of the package's assemblies: it is the host's assembly, not the package's, and listing it would have a framework that scans the catalog (feature discovery, for example) treat the host's assembly as the package's own. - The host must have a copy that satisfies the entry: one in its default context with the entry's major version. When a package that carries a shared assembly is loaded
HostIntegratedand the host has no such copy, the load is refused with an error naming the package, the assembly and the entry, rather than succeeding and failing later with aFileNotFoundException. Loaded in another mode, binding fails with the usualFileNotFoundExceptionorFileLoadExceptioninstead of falling back to the package's copy, andIPackageAssemblyCatalogskips the assembly with a warning. To refuse, at reconciliation, a package that needs a newer copy than the host has, also declare its package inHostProvidedPackages. -
PackageAssemblies.AssemblyReferencesstill lists the files a package carries, its copy of a shared assembly included. Load assemblies throughIPackageAssemblyCatalog, not from those paths; loading a file by path puts the package's copy into your own context.
-
Applicability:
Optional Module
Use a package activation gate when your application can check a pre-condition that must hold before a package graph is allowed to load — for example that a module's database schema is current. A feature-enable-time check in your own application only covers the moment a feature is switched on; a gate also covers the paths where no such call happens at all, such as a process restart or an automatic reconcile.
Implement IPackageActivationGate and decide from what you can read without loading the package:
public sealed class SchemaVersionActivationGate(ISchemaStateReader schemaState) : IPackageActivationGate
{
public async ValueTask<PackageActivationGateResult> EvaluateAsync(
PackageActivationContext context,
CancellationToken cancellationToken)
{
foreach (var package in context.Packages)
{
if (!await schemaState.IsCurrentAsync(package.InstallPath, cancellationToken))
{
return PackageActivationGateResult.Block(
$"The database schema for '{package.Id}' is behind version {package.Version}; run migrations before activating it.");
}
}
return PackageActivationGateResult.Allow;
}
}Register it on the loading builder; nothing is registered by default, so loading behaves exactly as before until you add a gate:
nuplane.AutoloadPackages(loading => loading.AddActivationGate<SchemaVersionActivationGate>());Rules the loading module guarantees:
- Gates run after the graph's load mode is selected and before any load context is created, so a blocked graph never loads a single assembly, in either
CollectibleorHostIntegratedmode. - Every registered gate is consulted, sequentially, in registration order. A block from any gate refuses the whole graph and every blocking reason is reported together. Other graphs in the same load pass are unaffected.
- A refused graph is not resolved at all, so every package in it is reported failed with the gate's reason — roots, dependencies, and members a successful load would have skipped because the host runtime already provides their assembly or because they carry none. Nothing of a graph may be processed before the gates allow it, so the loader does not resolve the graph first just to classify its members. Once the gates allow it, those members go back to being skipped and leave no failure behind.
- Gates are fail-closed: a gate that throws, or that returns no result, blocks the graph, and the failure names the gate type. A gate fault is never treated as an allow. A cancellation that honors the caller's token stays a cancellation and is not reported as a load failure.
- Gates are not consulted for a graph generation that is already loaded; they run when a graph is genuinely about to be activated.
- A blocked graph is never cached as loaded, so the next attempt — the next reconcile or the next process start — re-evaluates the gates and loads the graph as soon as they allow it. Nothing has to be reset by hand.
- Gates must not load or execute package code. Read package metadata from
PackageActivationContext.Packages[].InstallPathinstead. Nuplane does not tell a gate which graph members are roots and which are dependencies; derive that from metadata you read yourself if you need it.
What an operator sees when a gate blocks a graph during a reconcile:
- A
Warninglog per blocking gate, naming the gate type, the graph key, the package identities, and the gate-supplied reason. - An ordinary load failure for every package in that graph — the same shape a missing assembly or a resolution error produces: a recorded package failure for the
loadstage, a package-failed loading event, and a degraded reconciliation cycle. Because the graph is refused before it is resolved, that includes graph members a successful load would have skipped as host-provided or assembly-less; they stop being reported as failed as soon as the gates allow the graph. - No rollback of package state. Activation gating happens at the loading boundary, after the store transaction, so the package stays active and installed; it is simply not loaded in this process. The load-state surface reports it as
Failedwith the gate's reason in its diagnostics.
At startup the same failure makes the startup cycle degraded, so the configured StartupFailurePolicy decides what happens next — gating does not change that policy:
-
FailHost(the default) throwsNuplaneStartupReconciliationException, so a host whose pre-condition is not met does not start rather than starting with a half-valid module. -
UseLastKnownGoodreplays the last-known-good active set, where the gate is consulted again and blocks again, so startup recovery reportslast-known-good-load-failedand the host still does not start. -
StartDegradedstarts the host with the blocked package unloaded and the cycle marked degraded.
For the maintained end-to-end walkthrough, use:
samples/Nuplane.Sample.AspNetCore/Program.cssamples/Nuplane.Sample.AspNetCore/appsettings.jsonspecs/014-query-package-catalog/quickstart.md
Those sources own the deepest validation commands, route payload expectations, and sample behavior evidence.
This guide does not try to be:
- a plugin framework tutorial;
- a full operator runbook;
- the canonical route-by-route admin API reference;
- a phase-history change log.
For those topics, continue to repository-owned sources.
| Label | Meaning | Use it when |
|---|---|---|
Core |
Baseline current Nuplane behavior | The capability is part of the main current product story |
Optional Module |
Opt-in behavior owned by a module such as loading | Extra setup or module installation is required |
Phase-Based |
Capability or context tied to roadmap staging | The detail is not universal baseline behavior |
Recently Changed |
Terminology or reader-facing guidance changed recently | Readers may still see older wording in nearby material |
Evolving |
Current behavior is real, but the shape is still being refined | Avoid over-reading the section as permanently settled |
This wiki is a hybrid hub: it owns product framing, onboarding, architecture orientation, and terminology normalization; it intentionally links to repository-owned detail for deep validation, sample mechanics, roadmap evolution, and maintainer/runbook depth.
Canonical repository anchors for first-scope pages include:
README.mddocs/roadmap.mdsamples/Nuplane.Sample.AspNetCore/- accepted specs and quickstarts under
specs/
- Home
- Overview
- Getting Started
- Usage Guide
- Package Authoring
- Architecture Guide
- Concepts and Glossary
- Source References
- Evaluator: Home → Overview → Getting Started
- Integrator: Home → Overview → Getting Started → Usage Guide
- Package author: Home → Usage Guide → Package Authoring
- Contributor: Home → Overview → Architecture Guide → Concepts and Glossary