# Usage Guide ## Primary purpose This page helps integrators choose the right Nuplane adoption path and understand where baseline runtime usage ends and optional module behavior begins. ## What you are integrating 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: 1. Configure a directory-backed feed watching a local `packages` folder. 2. Enable automatic reconciliation so Nuplane reacts to file-system changes. 3. Enable the optional loading module so assemblies are loaded into isolated contexts. 4. Register observers so your host is notified when packages change. 5. 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. ## Common scenarios | 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 | ## Start with the decision table | 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`](../roadmap.md) and accepted specs rather than relying on the wiki alone | ## Core-runtime path - **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: 1. Configure feeds and reconciliation under the `Nuplane` section. 2. Let Nuplane reconcile desired vs actual package state. 3. Read authoritative package state from the active catalog or admin read surfaces. 4. Keep host decisions — cache invalidation, feature toggles, discovery, reload, and activation — in host code. ### Precedence for overlapping desired sources 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`. ```json { "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: ```csharp using Nuplane.Sources.Configuration; services.AddNuplane(configuration.GetSection("Nuplane"), nuplane => { nuplane.Services.Configure(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. ## Query-first integration guidance - **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. ### Offline reads of the active package set - **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. ```csharp 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`: ```csharp 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. ### Host-free loading of the active package set - **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: ```csharp 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`: ```csharp 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` instead, for callers that filter or assemble the set themselves — for example after reading it with `NuplaneStore.ReadActivePackagesAsync`: ```csharp 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: ```csharp 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//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. ### Packages the host already provides - **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). ```json { "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-unsatisfied` stage, 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 against `Elsa.Workflows.Core [4.1.0, 5.0.0)` on a host running `4.0.0`, with `Elsa.` 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 .runtimeconfig.json --depsfile .deps.json .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](#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: ```json { "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." ] } } ``` ### Host-free restore of the package set - **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. ```csharp 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](#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](#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: ```csharp 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:` 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](#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: ```csharp 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](#selecting-a-package-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:=