dversedev

Guide

Getting started

From an empty folder to a deployed, verified plugin. Every file shown in full.

Getting started: a new plugin project with dverse

This guide takes you from an empty folder to a deployed, verified plugin. Every file is shown in full, so you can follow it mechanically.

Prerequisites

  • .NET SDK 9.0 or later.
  • A Dataverse environment URL (e.g. https://yourorg.crm.dynamics.com) and a user with System Customizer or System Administrator.
  • The dverse CLI:
bash
dotnet tool install --global Dverse.Cli --prerelease
# Or pin an exact CI version: dotnet tool install --global Dverse.Cli --version 0.1.0-ci.<N>

dverse version   # which package version is installed, e.g. 0.1.0-ci.89, plus the commit it was built from

1. Repository layout

your-plugins/
├─ dataverse.yaml              # environment config — committed, never contains secrets
├─ dataverse.snapshot.json     # metadata snapshot — committed, produced by `dverse model sync`
└─ src/
   └─ MyCompany.Plugins/
      ├─ MyCompany.Plugins.csproj
      ├─ MyCompany.Plugins.snk # strong-name key (classic plugin assemblies must be signed)
      └─ AccountPlugins.cs

This guide builds one plugin assembly. For a repository with several assemblies (or web resources, PCF, schema, and forms alongside them), see solution-layout.md — it prescribes the full tree, when to split assemblies, and how a whole-repo dverse apply composes.

2. dataverse.yaml

yaml
default: dev

environments:
  dev:
    url: https://yourorg-dev.crm.dynamics.com
    solution: yoursolution      # created automatically if missing
    prefix: your                # publisher customization prefix (created if missing)

One environment is the normal case, and it is the sandbox you develop in. dverse converges that environment from your repository; getting to production is unchanged — export a managed solution and import it downstream, exactly as Power Platform ALM already works. dverse does not apply to production.

If several people each have their own sandbox, they add a gitignored dataverse.local.yaml with their own url and default: rather than a second block here. Add more environments: blocks only when you really do deploy from source to more than one org.

Environment resolution everywhere in dverse: --env <name> flag → DVERSE_ENV variable → default: in dataverse.local.yaml (gitignored personal overrides) → default: in dataverse.yaml. Nothing resolved is a hard error — dverse never prompts.

3. The project file

src/MyCompany.Plugins/MyCompany.Plugins.csproj:

xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net462</TargetFramework>
    <LangVersion>latest</LangVersion>
    <!-- Deterministic builds make dverse's sha256 upload-skip work: identical source ⇒ identical DLL. -->
    <Deterministic>true</Deterministic>
    <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
    <!-- Classic (non-package) plugin assemblies must be strong-named. -->
    <SignAssembly>true</SignAssembly>
    <AssemblyOriginatorKeyFile>MyCompany.Plugins.snk</AssemblyOriginatorKeyFile>
  </PropertyGroup>

  <ItemGroup>
    <!-- The only dverse reference a plugin project needs: attributes + generators + build targets.
         Pin an exact version — wildcards break deterministic builds. -->
    <PackageReference Include="Dverse.Sdk" Version="0.1.0-ci.42" />
    <!-- Dataverse SDK types (Entity, IPlugin, ...). -->
    <PackageReference Include="Microsoft.CrmSdk.CoreAssemblies" Version="9.0.2.56" />
    <!-- Reference assemblies so net462 builds on any OS / without VS. -->
    <PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
  </ItemGroup>

  <ItemGroup>
    <!-- The committed metadata snapshot feeds the source generators. -->
    <AdditionalFiles Include="..\..\dataverse.snapshot.json" Link="dataverse.snapshot.json" />
  </ItemGroup>

</Project>

Notes:

  • Never use a wildcard package version in a real project — pin it. Wildcards break build determinism and therefore the upload skip.
  • Generate the strong-name key once and commit it (it is a code-signing formality, not a secret in this context):
powershell
# PowerShell (any OS with pwsh + Windows crypto, or use `sn -k` from the SDK)
$rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider(2048)
[IO.File]::WriteAllBytes("src/MyCompany.Plugins/MyCompany.Plugins.snk", $rsa.ExportCspBlob($true))
  • The Dverse.Sdk build props automatically set EmitCompilerGeneratedFiles (how the registration manifest reaches the CLI) and DebugType=none (a PDB checksum would leak registration changes into the DLL hash; sandbox code has nothing to attach a debugger to anyway).

4. Sign in and snapshot the model

bash
dverse auth login            # device-code flow; prints a URL + code, then caches tokens (~/.dverse)
dverse whoami                # verify: prints user + org
dverse model sync            # entities come from dataverse.yaml (model: entities:)

Declare the entities once in dataverse.yaml:

yaml
model:
  entities: [account, contact]

From then on plan/apply keep the snapshot fresh automatically: they re-read live metadata, print a schema diff (~ model: account: +1 column (dverse_x)), update dataverse.snapshot.json, and build against it — commit the file with your change. dverse model sync remains for explicit refreshes; --entities a,b does a one-off sync.

model sync writes dataverse.snapshot.json (deterministic: sorted, no timestamps — clean git diffs). Commit it. The generator turns it into early-bound classes in memory — you will not see generated .cs files in your source tree. Re-run model sync whenever schema changes; schema drift then breaks the build instead of production.

Naming and label language (optional)

The model: block accepts three more keys — all optional, all flowing through the snapshot (the generator never reads dataverse.yaml):

yaml
model:
  entities: [account, contact]
  namespace: MyCo.Xrm          # generated-code namespace; default Dverse.Model
  language: 1033               # LCID for identifier labels; default 1033 (English)
  classNames:                  # per-entity class renames; default: the schema name
    account: CrmAccount
  • namespace / classNames rename what the generator emits (MyCo.Xrm.CrmAccount instead of Dverse.Model.Account). [Step(typeof(CrmAccount), …)] and CrmAccount.Cols.* resolve as usual; references by the original schema name keep working too.

  • language pins the labels that name optionset enum members, so the snapshot does not depend on who ran the sync. When the requested language is not provisioned in the org, model sync falls back to the organization base language and prints one warning:

    74 label(s) have no 1033 translation; fell back to the organization language 1031 … set 'model: language:'

    Act on it rather than scrolling past: the fallback language is what names your generated identifiers, so an org whose base language is not the one you assumed produces enum members you did not expect. The effective LCID is recorded in the snapshot (labelLanguage:), so a language change shows up as a committed diff — never as silently different identifiers per developer.

Relationships

The snapshot also carries relationships between snapshot entities (1:N, N:1, N:N). Each model class gets Rels constants with the relationship schema names, and the runtime adds one-line helpers:

csharp
// 1:N — children by their lookup column (full typed query: Select/Where/OrderBy/Top compose)
var contacts = ctx.Service
    .QueryChildren<Contact>(ctx.TargetRef, Contact.Cols.ParentCustomerId)
    .ToList();

// N:N — typed associate/disassociate
ctx.Service.Associate(Account.Rels.dverse_accounts_contacts, accountRef, contactRef);

Service principals (CI)

For unattended runs (pipelines, scheduled jobs) use an Entra app registration instead of the device-code flow. One-time setup:

  1. Register the app — in the Microsoft Entra admin center, ApplicationsApp registrations+ New registration. Name it, keep Accounts in this organizational directory only, register (no redirect URI needed). From Overview, note the Application (client) ID and Directory (tenant) ID.
  2. Create a client secretCertificates & secrets+ New client secret. Copy the secret value immediately; it is not shown again. No API permissions are required — access is granted per environment in the next step.
  3. Create the application user — in the Power Platform admin center, ManageEnvironments → your environment → SettingsUsers + permissionsApplication users+ New app user. + Add an app, pick the registration from step 1, choose a business unit, then edit security roles and assign System Customizer or higher. (One app user per app registration per environment.)

Then set three variables wherever dverse runs:

Variable Value
DVERSE_TENANT_ID Directory (tenant) ID
DVERSE_CLIENT_ID Application (client) ID
DVERSE_CLIENT_SECRET the client secret value

When all three are set they take precedence over any cached device-code login — dverse auth login is not needed (or possible) for a service principal. Tenant discovery is automatic too: dverse reads the authority from the 401 challenge an unauthenticated GET {org}/api/data/v9.2/ returns in WWW-Authenticate.

Secret hygiene: never commit the secret — .env is gitignored for local use; in CI store all three values in the secrets store (e.g. GitHub Actions secrets) and inject them as environment variables per step.

5. Your first step

src/MyCompany.Plugins/AccountPlugins.cs:

csharp
using Dverse;
using Dverse.Model;

namespace MyCompany.Plugins;

public partial class AccountPlugins
{
    [Step(typeof(Account), Msg.Update, Stage.PreOperation,
        Filtering = new[] { Account.Cols.CreditLimit })]
    public void BlockCreditLimitDowngrade(IStepContext<Account> ctx)
    {
        if (ctx.Target.CreditLimit < ctx.PreImage.CreditLimit)
            throw ctx.Fail("Credit limits can't be lowered.");
    }
}

What the generator does with this:

  • Emits an IPlugin entry class and the whole context runtime as internal source into your assembly — no base classes, no runtime DLL, no ILMerge.
  • Registers a pre-image containing exactly creditlimit, inferred from the ctx.PreImage.CreditLimit access. If you use the image in a way the analysis can't prove (e.g. var pre = ctx.PreImage;), you get error DVERSE021 and add [Image(ImageType.Pre, ...)] explicitly — dverse never silently registers all columns.
  • Emits the registration manifest as data that does not affect the compiled IL.

Rules the compiler enforces for you (selection): async steps must be PostOperation (DVERSE001), no pre-images on Create (DVERSE002), Filtering only on Update (DVERSE003), unique keys (DVERSE004), handlers take exactly one IStepContext<TEntity> matching the step's entity (DVERSE013).

Some of them fix themselves. Where a rule has an unambiguous mechanical fix, the package ships a code fix and your IDE offers it on the lightbulb (Ctrl+. / Alt+Enter):

Rule Offered fix
DVERSE001 async outside PostOperation Run the step on PostOperation or Make the step synchronous — both, because which one you meant is not in the code
DVERSE003 Filtering on a non-Update message Remove Filtering
DVERSE018 Filtering on a global step Remove Filtering
DVERSE019 [Image] on a global step Remove the image

Each is a deletion or a one-token change, and "Fix all occurrences" works. The other rules are diagnostics only: a fix is offered when there is exactly one correct edit, never when the tool would have to guess at intent. Removing a Filtering you meant to keep is a visible no-op; picking a stage for you is not.

Key is optional. It is the stable identity used to match steps against the environment and the trace-log prefix, and when you omit it dverse derives one from class, method, message and stage — accountplugins-blockcreditlimitdowngrade-update-20 for the step above. That is the normal case; most steps never need one written by hand.

Set an explicit Key when you want one of two things:

  • Rename stability. The derived key moves when you rename the class or the method, and dverse matches on it — so a rename reads as "delete the old step, create a new one" rather than an update. The registration ends up correct either way, but the step row is recreated and its id changes. Pin a Key on steps where you would rather rename freely.

    The same happens in reverse when you remove an explicit Key: the derived key takes over, and the registered name and step id change with it. The existing row is still found — the natural key (plugin type, message, entity, stage) matches it — so nothing is orphaned, but because a primary key cannot be changed in place the plan shows a recreate, and says which key it used to be:

    ~ step applicationplugins-queueenrichment-create-20 (registered id 2222… is not the derived 1111… — recreate)
        ! renamed from key 'kc_applications-queue-enrichment-create' — matched by natural key, so no
          registration is lost; add Key = "kc_applications-queue-enrichment-create" to the [Step] to
          keep the existing id and name
  • A readable name in the environment. The registered step is named dverse: {key}, so an explicit key is what an admin sees in the Plugin Registration Tool. Name = "..." (below) is the more direct way to control that.

The compiler tells you the one case where a key is required: the derived key does not include the entity, so two [Step] attributes on the same method for different entities with the same message and stage collide, and DVERSE004 asks for an explicit unique Key.

Skip needs no key either — Skip.Steps(new AccountPlugins().BlockCreditLimitDowngrade) takes a method group, so it follows the derived key automatically.

One thing the key now also decides: the step's sdkmessageprocessingstepid is derived from it (plus the assembly name), which is what lets Skip.Steps resolve without any environment lookup. Changing a key therefore re-derives the id, and plan shows the step as a recreate rather than a rename. Nothing is lost — a step row is registration, not data — but if you want the existing registration kept as-is, pin the old key with Key = "...".

Name and Description (optional). By default the registered step is named on the deterministic template dverse: {key}. Name = "..." overrides that (e.g. to keep a historical name, or to satisfy a naming convention) and Description = "..." fills the row's description — both diffed like any other field, both max 256 characters, empty ≡ omitted. Names must stay unique per assembly (they are the last-resort matching fallback); a duplicate is compile error DVERSE024, and so is a name starting with dverse: that isn't the step's own template — that prefix is reserved for key recovery. Note that a step with an overridden name no longer encodes its key in the environment — matching falls back to the natural key or the exact name. That is fine on its own, but it removes the recovery path a dverse: {key} name provides, so an overridden Name is one place where pinning Key (or Id) earns its keep.

Deployment (optional). Steps deploy server-only by default (sdkmessageprocessingstep.supporteddeployment = 0). For offline-capable orgs, Deployment = Deployment.Both (or Deployment.OfflineOnly) registers the step for the offline client too; the value is diffed like any other field, so dropping the override plans the reset to server-only.

Image Alias (optional). Registered images are aliased pre/post by default. [Image(ImageType.Pre, ..., Alias = "PreImage")] overrides the registered entityalias — useful for shared helper code that reads ctx.Raw.PreEntityImages by a historical alias (spkl's Image1Alias). Typed access (ctx.PreImage) is alias-agnostic either way. Aliases must be unique per step, and a step carries at most one pre and one post image (DVERSE025).

RunAsUser (optional). Impersonation, PRT's "Run in User's Context": RunAsUser = "<systemuser guid>" executes the step under that user instead of the calling user (sdkmessageprocessingstep.impersonatinguserid). A non-GUID value is compile error DVERSE026; dropping the property plans the reset to the calling user. The GUID is org-specific — a systemuser id does not port between environments, so hard-code it only in single-environment repos. For per-environment behavior prefer the portable alternatives: branch on an environment variable (ctx.Env) or a step configuration value, or use ctx.ServiceAs(userId) / ctx.SystemService inside the handler for per-call impersonation.

The context in 30 seconds

IStepContext<T> (full contract: dverse-context-api-spec.md):

  • ctx.Target — typed view of the submitted columns; mutations in Pre stages flow into the write (same transaction, no extra update).
  • ctx.PreImage / ctx.PostImage — snapshots, column sets inferred from your code.
  • ctx.Projected — merged "what will the row look like after this write" view (Update pre-stages).
  • ctx.IsSubmitted("col") — distinguish "not submitted" from "explicitly cleared".
  • ctx.Service / ctx.SystemService / ctx.ServiceAs(userId) — user / elevated / impersonated IOrganizationService.
  • ctx.Trace(...), throw ctx.Fail("message"), ctx.Shared (typed cross-step variables), ctx.UtcNow, ctx.Depth, ctx.CorrelationId; ctx.Raw / ctx.Services as escape hatches.

Step<Account> — the generic form

If you prefer the entity as a type argument, write it that way:

csharp
[Step<Account>(Msg.Update, Stage.PreOperation,
    Filtering = new[] { Account.Cols.CreditLimit })]
public void BlockCreditLimitDowngrade(IStepContext<Account> ctx) { ... }

This is the same registration as Step(typeof(Account), …) — same derived key, same step id, same manifest, same diagnostics. Both forms are supported and neither is deprecated, so you can mix them freely (even on one handler), and moving an existing step from one to the other deploys as a no-op rather than a recreate.

It works on net462 because every dverse attribute is [Conditional("DVERSE_MANIFEST")]: the application never reaches IL, so the .NET Framework runtime never reflects over a generic attribute. Only the compiler and the generator ever see it.

The generic form names a model class, so the two forms in the next section — which have no type to name — keep the typeof/string constructors.

Untyped and global steps

Two [Step] forms skip the model class; both take the non-generic IStepContext (DVERSE013 enforces the pairing):

csharp
// Entity by logical name — no snapshot entry, no generated class needed.
[Step("dverse_widget", Msg.Update, Stage.PreOperation,
    Filtering = new[] { "dverse_name" })]
public static void AuditWidget(IStepContext ctx)
    => ctx.Trace("widget {0} renamed", ctx.Target.Id); // ctx.Target is a raw Entity

// Global (entity-less) message — e.g. the message of an unbound custom API or a
// global custom action. No entity, no sdkmessagefilter bind on the registration.
[Step("dverse_Reverse", Stage.PostOperation)]
public static void AuditReverse(IStepContext ctx)
    => ctx.Raw.OutputParameters["Audited"] = true;

The untyped context exposes raw Entity values, so image columns are never inferred — any ctx.PreImage/ctx.PostImage use requires an explicit [Image(ImageType.Pre, "col", ...)] (or ImageScope.All); otherwise DVERSE021. Global messages have no entity Target at all (ctx.Target throws, naming the message): their parameters live in ctx.Raw.InputParameters and ctx.Raw.OutputParameters — PostOperation sync mutations of output parameters still reach the caller's response. Filtering and [Image] on a global step are compile errors (DVERSE018/DVERSE019). Test untyped handlers with the untyped double: var ctx = TestStep.For("dverse_Reverse", Stage.PostOperation); ctx.OutputParameters[...] = ...;.

Working with raw entities

When there is no model class — the table is a runtime parameter, the table isn't in the snapshot, or the handler is generic over logical names — the raw surface keeps the code readable without making it silently wrong:

csharp
// Column-scoped retrieve by logical name (zero columns is refused; RetrieveAll is the
// spelled-out all-columns read). RetrieveOrNull answers null only for a missing row.
var config = ctx.SystemService.Retrieve("contoso_templateconfig", configId,
    "contoso_name", "contoso_templateid", "contoso_environment", "contoso_deliverymode");

// Reads convert: choices to int/your enum, currency to decimal, lookups to reference or id,
// aliased link columns unwrapped. A mismatch names the column, the stored type and the fix.
var templateId  = config.GetRequired<string>("contoso_templateid");   // absent/blank ⇒ throws, naming the row
var environment = config.Get<Environment?>("contoso_environment");     // your own enum, no generated class
var label       = config.Formatted("contoso_environment");             // the platform's label, or null

// One query for "does the caller hold one of these roles" — joined, not two round trips.
var permitted = ctx.SystemService.Query("role")
    .WhereIn("name", allowedRoles)
    .Link("systemuserroles", "roleid", "roleid", link => link.Where("systemuserid", ctx.UserId))
    .Any();

// Writes chain, and wrappers are named instead of constructed.
var documentId = ctx.SystemService.Create(new Entity("contoso_document")
    .Set("contoso_name", name)
    .SetOption("contoso_status", StatusOption.Succeeded)
    .SetRef("contoso_templateconfigid", "contoso_templateconfig", configId)
    .SetIfNotNull("contoso_commitsha", commitSha));

// File columns have no create/update path at all — these three messages are it.
ctx.SystemService.UploadFile(new EntityReference("contoso_document", documentId),
    "contoso_file", fileName, pdfBytes);

ctx.Tracing hands the platform ITracingService to helper classes that take one (new ReportApiClient(url, key, ctx.Tracing)); handler code should still use ctx.Trace(...). service.Query(...).ToList() pages past 5000 rows unless you Top(...) it, Build() returns the underlying QueryExpression for anything the builder doesn't express, and the test doubles cover all of it — link joins with aliased columns, the file block protocol (ctx.Data.File(reference, column)), and the real not-found error code.

Shaping queries: RetrieveMultiple steps

Steps on Msg.RetrieveMultiple (Pre stages only) can rewrite the query before it executes — the classic use is injecting a filter into every query for a table. Callers query in three different shapes (QueryExpression, FetchXML, QueryByAttribute); ctx.Query normalizes all of them into one mutable QueryExpression, so you write the filter once:

csharp
[Step(typeof(Account), Msg.RetrieveMultiple, Stage.PreOperation)]
public static void HideArchived(IStepContext<Account> ctx)
{
    if (ctx.IsAggregateQuery)
        return; // aggregate FetchXML (charts/dashboards) can't be normalized — skip, don't fail

    ctx.Query.RestrictWith(Account.Cols.StateCode,
        ConditionOperator.NotEqual, (int)account_statecode.Inaktiv);
}

Use RestrictWith — not Criteria.AddFilter — to add restrictions: AddFilter attaches your filter as a child of the caller's root, so when a caller sends OR-rooted criteria (name eq A OR name eq B) your restriction becomes just another OR branch and the query matches nearly everything. RestrictWith re-roots to And(callerCriteria, restriction) in place. Always start with the ctx.IsAggregateQuery guard: aggregate FetchXML is the one shape ctx.Query refuses (accessing it throws — which would fail the caller's charts and dashboards), and the guard is a cheap probe that lets those queries pass through unfiltered. Mutations reach the platform for QueryExpression (in place) and FetchXML callers (converted on access, written back after your handler — one platform call each way, only when you touch ctx.Query on a fetch-shaped query). A mutated QueryByAttribute query fails with a clear error instead of silently dropping your filter. ctx.Query anywhere else is compile error DVERSE032. Test with any shape: TestStep.For<Account>(Msg.RetrieveMultiple, Stage.PreOperation, query: new FetchExpression("<fetch>...</fetch>")), then assert on ctx.Query (and ctx.FlushQuery() to exercise the write-back). Registering on RetrieveMultiple puts your code on every query for the table — keep these handlers tiny and fast.

6. Deploy

bash
dverse plan                            # from the project directory: builds, then shows the diff
dverse apply                           # builds, then applies — or use the MSBuild targets below
dotnet build -t:Deploy                 # same as apply, from MSBuild
dotnet build -t:Plan                   # dry run — prints the diff; the build succeeds either way
dotnet build -t:Plan -p:DversePlanFailOnChanges=true   # CI drift gate: fail the build on pending changes

Pending changes surface as warning DVERSE100 (the .NET terminal logger only renders warnings/errors; the full diff is written straight to your terminal). Suppress it with <MSBuildWarningsAsMessages>DVERSE100</MSBuildWarningsAsMessages> if you prefer silence.

bash
dverse plan --assembly ... --json      # raw CLI keeps exit codes: 0 = clean, 2 = changes, 1 = error

apply ensures the publisher + solution exist, uploads the assembly only when its sha256 changed, and syncs steps/images/custom APIs field-by-field. Components no longer in code are deleted automatically — the code is the source of truth (pass --no-prune to keep them all, or --no-prune=commands,webresources to keep just those kinds). A component kind you declare nothing for is never pruned at all. Everything lands in the solution from dataverse.yaml.

The deploy is scoped to the directory you run from: inside one project's directory only that project (plus environment variables) is built and diffed; configured web resources, PCF projects, and sibling assemblies outside the current directory are skipped with a stderr note. Run from the repo root — or pass --all — for the full configured scope (see solution-layout.md).

Verify idempotency any time: run apply twice — the second run must report 0 writes.

Versions increase automatically. Deployments with stale version numbers cause real trouble: a plugin assembly whose version never changes can be skipped by downstream managed-solution updates, and a PCF control update without a version bump isn't picked up at all (server- and browser-side caches key on it). So when apply sees changed content whose version didn't increase past the environment's, it bumps the source — the csproj's AssemblyVersion revision (major.minor stay put, so the in-place update remains valid) or the control's ControlManifest.Input.xml patch version — rebuilds once, and deploys the new version. plan reports the pending bump (! version bump pending: …) without touching anything. Commit the bumped files with your change; if you bump versions yourself, dverse sees the increase and stays out of the way. Opt out with --no-bump; with --no-build or an explicit --assembly there is nothing dverse can rebuild, so it warns instead.

Write performance. apply sends its writes as Dataverse $batch requests: each component's writes (a step with its images and secure config, a custom API with its parameters) form one atomic changeset — all-or-nothing on the server — and independent components go out in parallel, capped by the org's recommended degree of parallelism (the x-ms-dop-hint response header). Single-write components (prune deletes, plugin type creates) are packed together into shared batches, so a 300-step deploy costs far fewer, mostly concurrent round trips instead of a thousand sequential ones; the reported write count is the same either way. If batching ever misbehaves (a proxy that mangles multipart bodies, or when isolating a server error), --no-batch falls back to the classic one-request-per-write sequence.

Adopting an existing registration

If the environment already has steps registered by another tool (spkl, the Plugin Registration Tool), don't retype them — scaffold them with their ids pinned:

bash
dverse adopt --assembly OldCompany.Plugins --out src/MyCompany.Plugins/Adopted.cs

Read-only against the environment; emits partial classes with [Step(..., Id = "...")] attributes and NotImplementedException handler bodies — port the real logic before deploying. The pinned Ids mean this works even when your new assembly has a different name: the first dverse apply moves the existing step rows onto the new assembly's plugin types in place. The old assembly is never touched — decommission it manually once the cutover is verified. See migrating-from-spkl.md section 4 for the full workflow.

Plugin packages (dependent assemblies)

Need a NuGet dependency (Newtonsoft.Json, a shared library) inside your plugin? Deploy as a plugin package instead of a classic single assembly — Microsoft's recommended model going forward:

xml
<PropertyGroup>
  <DversePluginPackage>true</DversePluginPackage>
  <!-- optional; defaults to the assembly name -->
  <DversePackageId>MyCompanyPlugins</DversePackageId>
</PropertyGroup>

The csproj properties are all you need: both the MSBuild targets and the bare CLI read <DversePluginPackage>/<DversePackageId> from the resolved project, so plain dverse apply deploys in package mode without flags. --package [--package-id <id>] remains for explicit control (e.g. with --assembly <dll>, where there is no project to read); --package-id implies --package, and a yaml assemblies: entry's package:/packageId: override the csproj. What changes:

  • dverse packs the plugin DLL plus its transitive dependency closure (the assemblies your plugin actually references, resolved against the output directory) into a nupkg (lib/net462/, deterministic bytes) and upserts it as a pluginpackage row named {prefix}_{packageId} — the publisher prefix comes from prefix: in dataverse.yaml, which package mode requires. Never packed: sandbox-provided assemblies (Microsoft.Xrm.Sdk, Microsoft.Crm.Sdk.Proxy, Microsoft.Xrm.Sdk.Workflow, Microsoft.PowerPlatform.Dataverse.Client), Dverse.Sdk (compile-time only), and the netstandard2.0 compatibility facades the .NET SDK copy-locals next to net462 projects — the sandbox's .NET Framework provides those (System.Text.Json is packed if you reference it — the sandbox copy may differ from yours).
  • Strong-naming is not required in package mode — you can drop the .snk (but if you keep signing, every dependency must be signed too).
  • The server registers the contained plugin assemblies and types from the package content; the upload skip compares the set of contained assemblies by sha256, so an unchanged build is still zero writes. Steps, images and custom APIs diff exactly as in classic mode.
  • Workflow activities are not supported in packages (platform limitation), and the package's unique name and version are immutable once created — dverse updates content only.
  • There is no automatic migration between classic and package registration: an assembly already registered the other way is a hard error. Unregister the old component first (steps included), then deploy in the new mode.

Code signing and managed identity

A plugin can reach Azure resources without storing any credential by running as a managed identity (ctx.ManagedIdentity). The platform ties that identity to the certificate the assembly was signed with — an unsigned assembly is refused outright:

Plugin assembly must be signed with valid certificate to associate to Managed Identity

This is Authenticode signing with a code-signing certificate (.pfx), which is a different thing from the strong-name .snk of section 3: strong-naming gives a classic assembly its identity, Authenticode says who vouches for the bytes. Managed identity needs the latter; a classic assembly needs both.

Create a certificate (self-signed — development only; production should use a certificate from a trusted issuer):

bash
dverse sign init

It writes certs/plugins.pfx, prints a generated password once, adds *.pfx to .gitignore, and prints the block to add to dataverse.yaml:

yaml
signing:
  certificate: certs/plugins.pfx
  password: ${DVERSE_SIGN_PASSWORD}     # read from the environment — never a literal here

signing: at the top level applies to every environment; an environment can override it with its own signing: (a separate production certificate). From then on, plan and apply sign the built assembly before it is read — including plan, so the hash it compares is the hash of what would actually deploy. The signature is deterministic (no signing time, no timestamp), so a no-op deploy is still zero writes.

Then tell dverse which identity to bind assemblies to:

yaml
environments:
  prod:
    managedIdentity:
      applicationId: 00000000-0000-0000-0000-000000000000   # the app registration / UAMI client id
      name: Contoso Key Vault reader                        # optional; defaults to "<solution> plugin managed identity"
      tenantId: 00000000-0000-0000-0000-000000000000        # optional; defaults to the environment's own tenant

apply creates the managedidentity row if the environment has none for that application id and binds the assembly to it; both are diffed, so a converged environment plans clean. An identity someone else registered is reused as-is — dverse only renames it if you set name: explicitly.

The other half of the setup is in Azure, and dverse sign info prints exactly what to enter:

bash
dverse sign info --env prod

It reads the environment and tenant ids from the environment itself and prints the issuer, audience and explicit subject identifier for the federated credential (Azure portal → your app registration or user-assigned managed identity → Certificates & secrets → Federated credentials → Add → Other issuer). Self-signed certificates use the /h/<certificate SHA-256> form, trusted-issuer certificates the version-2 /i/<issuer hash>/s/<subject hash> form; sign info picks the right one for the certificate you configured.

Rotating the certificate means adding a new federated credential in Azure for the new subject identifier — otherwise every token acquisition fails with AADSTS700213.

Not yet supported: signing plugin packages. A nupkg carries a NuGet package signature rather than an Authenticode signature, and dverse refuses rather than producing something that looks signed but isn't — deploy classically, or sign the package yourself with nuget sign.

7. Beyond steps

Custom APIs — request/response are plain records; parameters and typed callers are generated:

csharp
public sealed record ReverseRequest(string? Text);
public sealed record ReverseResponse(string Reversed, int Length);

public static class GeneralApis
{
    [CustomApi("your_Reverse", DisplayName = "Reverse a string")]
    public static ReverseResponse Reverse(ReverseRequest request, IApiContext ctx)
    {
        var chars = (request.Text ?? "").ToCharArray();
        System.Array.Reverse(chars);
        return new ReverseResponse(new string(chars), request.Text?.Length ?? 0);
    }
}
// From any other plugin: var resp = ctx.Service.your_Reverse(new ReverseRequest("abc"));

Bind to an entity with BoundTo = typeof(Account) and take IApiContext<Account>ctx.EntityRef is free, ctx.Entity does one column-scoped retrieve (columns inferred). If the entity escapes direct-member-access analysis (e.g. var a = ctx.Entity;), you get error DVERSE022 and declare the columns yourself: [Fetch(Account.Cols.Name, ...)] (merged with the inferred ones) or [Fetch(FetchScope.All)] for a deliberate over-fetch.

Omitted vs. null — the platform leaves optional parameters the caller never set out of InputParameters entirely; a plain string? field turns both "omitted" and "explicit null" into the same null. Wrap the field in Optional<T> when the difference matters (the "only update the fields the caller provided" API):

csharp
public sealed record UpsertPartnerRequest(string? AccountNumber, Optional<string?> Name = default);

// in the handler:
if (request.Name.IsProvided)      // omitted → false; explicit null → true
    account.Name = request.Name.Value;   // null clears the column

The parameter registers with isoptional = true, plain values still convert implicitly (new UpsertPartnerRequest("A-1", "Contoso") keeps compiling), and the generated typed caller leaves omitted fields off the wire while sending explicit nulls. Optional<T> on a response property is compile error DVERSE028 — responses are always owned by the handler.

Upgrade note: migrating an existing parameter from T? to Optional<T?> is a behavior change for deployed APIs — an explicit null used to be indistinguishable from omitted (a no-op in the typical if-not-null handler) and now clears the column. Callers that serialize unset fields as JSON null (common in ERP-style integrations) will start wiping data on your next apply; they must omit unset fields instead of sending nulls.

Derived columns — the most common plugin in existence, as one attribute:

csharp
public static class AccountRules
{
    [Derive(Account.Cols.Description)]
    internal static string? Description(Account a)
        => a.Name is null ? null : $"Account: {a.Name}";
}

Compiles to Create + Update PreOperation steps; sources (name) are inferred and become the filtering attributes + pre-image; multiple [Derive]s per entity coalesce into one handler in dependency order; the write happens in-place (zero extra messages, no recursion). Methods must be pure over the row (DVERSE052), no cycles (DVERSE051). The analysis follows calls into same-assembly static helpers — their column reads count as sources too — and whitelists trivially pure framework surface (Math.*, string.*, numeric/temporal value types, …); any other call (instance methods, other assemblies, delegates) is error DVERSE053: extract the logic into a static helper, or read the columns first and pass their values (the derived-column section has the full contract).

Step configuration — deploy-time values on the attribute, typed at the handler:

csharp
public sealed record GuardConfig(decimal Threshold = 0m, string Label = "");

public class AccountPlugins
{
    [Step(typeof(Account), Msg.Update, Stage.PreOperation,
        UnsecureConfig = """{ "threshold": 100000, "label": "gold" }""")]
    public static void Guard(IStepContext<Account> ctx, GuardConfig config)
    {
        if (ctx.Target.CreditLimit > config.Threshold)
            throw ctx.Fail($"{config.Label}: limit exceeds {config.Threshold}.");
    }
}

UnsecureConfig lands in sdkmessageprocessingstep.configuration, SecureConfig in its own sdkmessageprocessingstepsecureconfig row; both are diffed like any other field (the plan masks secure values as (secure)). The config record parameter is optional — one flat record of primitives, parsed once per plugin instance; add [Secure] on it to bind the secure string instead. Null/empty config binds as {}, so keep constructor defaults on the members. In tests, Dverse.Testing.TestConfig.Parse<GuardConfig>(json) binds JSON exactly like the entry class.

Security caveat: SecureConfig values live in your source and the build's manifest — anyone with repo read access sees them. Secure-vs-unsecure is about org-side visibility (secure configs are admin-only readable in Dataverse, which also means plan/apply of such steps needs an admin-level caller); it does not make the repo a safe place for secrets. Store vault references, not raw secrets — or use a Secret environment variable instead.

Workflow activities — classic CodeActivity classes in the same assembly are registered declaratively (add <Reference Include="System.Activities" /> to the csproj):

csharp
using System.Activities;

[WorkflowActivity(Name = "Reverse Text", Group = "My Company")]
public sealed class ReverseTextActivity : CodeActivity
{
    protected override void Execute(CodeActivityContext context) { /* ... */ }
}

No entry class is generated — the class itself is the registered plugin type; dverse creates the plugintype row and keeps Name (workflow-designer menu text, defaults to the full type name; also mirrored into friendlyname) and Group (submenu, workflowactivitygroupname, omitted when unset) in sync. The class must be a concrete, non-generic class deriving from System.Activities.CodeActivity (DVERSE081) — generic activities (including classes nested in a generic container) can't be registered as plugin types. Nesting itself is fine: nested classes register under their CLR metadata name (Ns.Outer+Inner).

Environment workflow activity types the manifest doesn't name follow the assembly's IL:

  • Class still in the DLL (e.g. an unattributed legacy activity): never pruned — plans report it as ! workflow activity ... — left alone, so mixed assemblies can migrate their steps first and adopt activities one by one.
  • Class gone from the DLL (you deleted or renamed a CodeActivity): the platform rejects any assembly content update while the row remains, so the row is deleted under prune (the default) before the content changes. With --no-prune (or --no-prune=steps), apply refuses up front (before any write) and names the orphaned types — rerun with prune or restore the class.

Web resources — a local directory (typically a TypeScript build's dist/) synced by content hash, declared per environment in dataverse.yaml:

yaml
environments:
  dev:
    url: https://yourorg.crm.dynamics.com
    solution: yoursolution
    prefix: your
    webresources:
      - path: webresources/dist     # deployable files, relative to dataverse.yaml
        prefix: your_/              # environment uniquename prefix — the management boundary
        solution: yourwebsolution   # defaults to the environment's solution
        build: npm run build        # optional, runs before files are read
        buildDir: webresources      # working dir for build; defaults to path's parent

Every file under path (by extension: .js, .html, .css, .png, …) becomes {prefix}{relative path}dist/forms/account.jsyour_/forms/account.js. dverse plan/apply diff them like everything else: unchanged content is zero writes, changed/created resources are updated and published in a single PublishXml call, and env resources under the prefix with no local file are pruned (the code is the source of truth; --no-prune=webresources keeps them, as does prune: false on the set).

Legacy resources whose uniquenames don't follow the convention (new_/…, unprefixed) can be managed without renaming them — a rename would break every form and ribbon reference. Map the local file to the existing uniquename explicitly:

yaml
    webresources:
      - path: webresources/dist
        prefix: your_/
        map:
          legacy/account.js: new_/account.js   # local path (relative to 'path') → env uniquename
          lib/jquery: { name: your_/lib/jquery, type: 3 }   # explicit webresourcetype

Mapped resources are fully managed under their legacy names — content-diffed, created if missing, counted as yours for prune purposes. The map wins over the extension filter, so files whose extension isn't deployable can still be managed: the type falls back from the file's extension to the uniquename's extension to an explicit type: (the webresourcetype value, 1 HTML / 2 CSS / 3 JScript / 4 XML / 5 PNG … 12 RESX) — required when neither has one, e.g. an extensionless legacy script. A map: entry that matches no local file is a hard error at plan time (typo protection), as are two files mapping to one uniquename. Nonconforming resources you don't map stay entirely out of scope: never read, never pruned. Two more guardrails: a local file whose type differs from the environment row's (webresourcetype can't change on an existing resource) and a prefix-named env row whose local file has no derivable type (the sync couldn't recreate what prune would delete) are both hard errors with the fix in the message, never a silent wrong write.

To adopt web resources that already live in the environment (spkl's download-webresources), pull them into the local directory instead of retyping them:

bash
dverse webresources pull [--env <name>] [--set <path>] [--force]

Read-only against the environment. Discovery is wider than the sync's scope: every web resource in the set's solution is found (via its solutioncomponents membership), plus anything under the prefix/map, so legacy rows with foreign names show up. Convention-named resources land at their derived relative paths; nonconforming names land at their uniquename-as-path (sanitized), and the command prints a ready-to-paste map: snippet for every resource the sync couldn't otherwise reproduce — nonconforming names, and names whose type isn't derivable from the file extension (the snippet pins type: then). dataverse.yaml is never edited automatically. Existing local files whose content differs are reported (differs (use --force)) and only overwritten with --force. --set limits the pull to one configured set (by its path:).

Localized errorsctx.FailLocalized resolves failure messages from .resx web resources by the user's language:

csharp
[assembly: LocalizedStrings("your_/strings")]   // web resource base name, once per assembly

[Step(typeof(Account), Msg.Update, Stage.PreOperation)]
public static void Guard(IStepContext<Account> ctx)
{
    if (ctx.Target.CreditLimit < ctx.PreImage.CreditLimit)
        throw ctx.FailLocalized("CreditLowered", ctx.PreImage.CreditLimit);
}

Put strings.1033.resx (and strings.1031.resx, …) in your web resource directory; they deploy through the normal sync as your_/strings.1033.resx (.resx is webresourcetype 12). At runtime the key resolves per user — {base}.{ctx.UserLcid}.resx{base}.1033.resx{base}.resx, per key — and the string is string.Formatted with the args in the user's culture. ctx.UserLcid is lazy (one usersettings.uilanguageid retrieve per execution, degrading to 1033); resources are cached per organization for five minutes — hits and misses — so the hot path stays at about one query per resource while a strings-only redeploy (which changes no IL and therefore recycles nothing) is picked up within minutes. A missing key or resource throws naming both — never a silent placeholder. Calling FailLocalized in a handler without the assembly-level [LocalizedStrings] is compile error DVERSE029. In tests, seed the doubles:

csharp
ctx.UserLcidValue = 1031;
ctx.LocalizedStrings["CreditLowered"] = "Das Kreditlimit ({0}) darf nicht sinken.";

PCF controls — code components (Power Apps component framework) are scaffolded, built and deployed by the same plan/apply loop (requires node + npm):

bash
dverse pcf init --name CreditGauge                    # scaffolds pcfcontrols/CreditGauge
# or:  --namespace MyCo --template dataset --path controls/CreditGauge --install

The scaffold is the standard pcf-scripts layout — the same files pac pcf init creates (package.json, tsconfig.json, pcfconfig.json, eslint config, <Name>/ControlManifest.Input.xml, <Name>/index.ts) minus the msbuild .pcfproj, which dverse replaces. npm run build (or npm start for the test harness) works exactly as in a pac project. Then declare the project per environment:

yaml
environments:
  dev:
    url: https://yourorg.crm.dynamics.com
    solution: yoursolution
    prefix: your
    pcf:
      - path: pcfcontrols/CreditGauge   # project dir, relative to dataverse.yaml
        solution: yoursolution          # optional; defaults to the environment's solution
        build: npm run build            # optional; this is the default (skip via --no-build)

What plan/apply do with it:

  • run the project's build, then read every built control under its out directory (pcfconfig.json outDir, default out/controls);
  • diff each control against the live customcontrol row — version, manifest (whitespace- and attribute-order-insensitive), and the content hash of every manifest-declared resource (bundle.js, css, resx). An unchanged control is zero writes;
  • for changed/new controls, pack a throwaway solution zip and ImportSolution it — the same mechanism pac pcf push uses, because the platform has no supported field-level write path for custom controls — then add the control to your target solution and delete the stage solution container again (dverse_pcf_stage; the control survives, the container goes);
  • name new controls {prefix}_{Namespace}.{Name} with the target solution's actual publisher prefix (read live, like modern pac). A control that already exists under the legacy unprefixed name is managed under that name instead of being duplicated.

Versions take care of themselves: a control update is only picked up when its manifest version increases, so apply auto-bumps the ControlManifest.Input.xml patch version when the content changed without one (see "Versions increase automatically" in section 6) — no more stale-cache mysteries after a deploy.

Because a changed bundle re-imports the whole control, there is no partial update to reason about; a bundle rebuilt with identical content stays clean. Note that dverse never deletes custom controls: removing the project from pcf: leaves the control in the environment (forms may still reference it) — delete it manually when it is truly unused.

Command-bar buttons — a button that calls a custom API is the attribute plus nothing else:

csharp
[CustomApi("your_RecalculateAccount", DisplayName = "Recalculate an account", BoundTo = typeof(Account))]
[Command(typeof(Account), "Recalculate",
    Location = Ribbon.Form,
    Icon = "Refresh",
    Confirm = "Recalculate this account?",
    Progress = "Recalculating…",
    Success = "Recalculated {response.Recalculated} value(s).",
    Refresh = Refresh.Form)]
public static RecalculateResponse Recalculate(RecalculateRequest request, IApiContext<Account> ctx)

dotnet build -t:Deploy and the button exists, confirms, shows a progress indicator, calls the API, reports the result and refreshes the form. No ribbon XML and no hand-written JavaScript: the generator records the facts in the manifest, and the CLI renders an ES5 script into a dverse-owned web resource ({prefix}_/dverse/commands/{Assembly}.js) that the button binds to. That split is deliberate — the script runs in end-user browsers, so a template fix ships with a CLI upgrade rather than a rebuild of every plugin assembly. It also means [Command] adds no types to the compilation: a label change cannot move the assembly hash.

Behaviour comes in three tiers, and you only reach for the next when the previous runs out.

Tier 1 — declarative. Confirm / ConfirmTitle / ConfirmButton (cancelling skips the call), Progress (always closed in a finally, so a failure cannot leave the app spinning), Success + SuccessStyle (Toast / Dialog / None) with {response.Field} templating validated against the API's response record at compile time, Refresh (Form / Grid / None), and Navigate + NavigateTarget (OpenRecord / OpenUrl / Close). Failures show ctx.Fail's message and error code verbatim.

Tier 2 — lifecycle hooks, each webresource#function pointing at TypeScript you already build and deploy: Before (runs first, before the confirm dialog; returning false aborts silently, and it may return a payload merged into the request), After (on success, before refresh/navigate) and OnError (replaces the default dialog). Each receives one stable context object, so a hook signature does not break when the wrapper changes.

The web-resource half of the reference is a dependency declaration, and dverse acts on it. If the function is not on window when the button is clicked, the runtime loads that web resource (once per page, through getWebResourceUrl so a redeploy is never served stale) and then calls it. You do not have to add it under Form Properties → Form Libraries, and a grid or sub-grid button — which has no form to attach a library to — can use hooks for the same reason. If the file cannot be loaded, or loads without defining the function, the error names the web resource and the function rather than failing somewhere inside the generated script.

One thing the runtime cannot do for you: a bundler that keeps your function module-local leaves nothing on window to find. Assign it explicitly (window.KC = KC) or configure a global output format.

A Before payload is the only way a button supplies request parameters, so the generated caller declares your API's parameters in getMetadata().parameterTypesXrm.WebApi serializes only what is declared there and drops the rest silently. Parameters typed Entity, EntityReference or EntityCollection are the exception: their wire type is mscrm.{logical} of the record being sent, which the manifest cannot know, so they stay undeclared and a hook needing one calls Dverse.Api.invoke(...) with its own parameterTypes. A request parameter that is neither Optional<T> nor nullable and has no Before hook to supply it is DVERSE096 at compile time — the button could never satisfy it, and the platform rejects the call before your handler runs.

Tier 3 — full override. Handler = "your_/account.js#recalc" makes dverse generate no body at all: the button points straight at your function, and you keep the registration, the field-level diff, the prune and the solution membership. The generated script still exports Dverse.Api.invoke(...) and Dverse.Api.invokeMany(...), so the escape hatch keeps the plumbing instead of re-implementing Xrm.WebApi.online.execute metadata by hand. Handler combined with any Tier-1 or Tier-2 property is DVERSE097 — one mechanism at a time.

Grid buttons hand the whole selection to one unbound call: dverse supplies Records (a string[] of the selected ids, declared as a StringArray parameter) and EntityName, so the handler decides how to process the set rather than the caller splitting a delimited string.

csharp
public sealed record BulkRecalculateRequest(string[]? Records, string? EntityName);

[CustomApi("your_BulkRecalculateAccount")]
[Command(typeof(Account), "Recalculate selected",
    Location = Ribbon.MainGrid, Refresh = Refresh.Grid, RequireSelection = 1)]
public static BulkRecalculateResponse BulkRecalculate(BulkRecalculateRequest request, IApiContext ctx)

ConditionsEnableWhen = When.ExistingRecord (hide on an unsaved form), EnableWhenNotEmpty / EnableWhenEmpty (a column has or lacks a value), EnableWhenField, RequireSelection (grid selection count) and EnableWhenJs (your own rule function, with EnableWhenJsDefault deciding what a not-yet-loaded rule returns). These are the reason the next two sections exist: a modern command cannot carry a customer-authored visibility rule at all. Dataverse refuses to create appactionrule rows outside Microsoft first-party solutions, and the Power Fx alternative needs a component library with no API authoring path. So when a [Command] declares any condition, dverse deploys it as a classic ribbon fragment instead — same attribute, different mechanism underneath. An unconditional command stays a modern appaction.

The compiler covers the rest: [Command] on a non-[CustomApi] method is DVERSE091, an entity-required location without a typeof is DVERSE092 (and the reverse DVERSE093), a duplicate key DVERSE094, a form command whose API is not bound to that table DVERSE095, a request parameter the button cannot supply DVERSE096, and a malformed hook reference DVERSE098. AllowMultiple is on, so one handler can carry a Form button and a Main Grid button; each gets its own key and row.

The derived key is class-method-location, so several [Command]s on one handler only need explicit Keys when two of them share a location — two grid buttons on different tables, say. That collision is DVERSE094 on each offending attribute, never a silently dropped button.

Modern commands (commands:) — buttons that are not backed by one of your custom APIs (they call your own JavaScript, or they belong to a table you did not write the plugin for) are declared as YAML instead:

yaml
    commands:
      - path: commands            # commands/*.yaml
        prefix: your_             # the management boundary; defaults to '{publisher prefix}_'
        solution: MySolution      # optional; defaults to the environment solution
        prune: false              # optional; keep env-only commands under this prefix
yaml
# commands/dverse_job.yaml
commands:
  - uniqueName: dverse_job_requeue
    label: Requeue
    tooltip: Requeue
    description: Put this job back on the queue.
    location: Form                # Form | MainGrid | SubGrid | AssociatedGrid
    entity: dverse_job
    fontIcon: Refresh             # or icon: a web-resource name
    javascript:
      webResource: dverse_/job_form.js
      function: Dverse.Job.requeue

appaction rows are ordinary CRUD, so unlike ribbons these get a real field-level diff: a changed label is one ~ and one PATCH of one column, and a no-op plan is zero writes.

The boundary is prefix:, and declaring nothing manages nothing. Like web resources, a command set's prefix: is the management boundary — only uniquenames under it are read, diffed and pruned. Two consequences worth being explicit about:

  • With no commands: entry at all, dverse manages no hand-authored commands and prunes none. It reads only its own reserved namespace ({prefix}_dverse.command.…, where [Command] deploys), so a maker-portal button that happens to share your publisher prefix is never even read. Adopting dverse into an org whose prefix predates the repo cannot propose deleting the buttons someone built there.
  • Once you do declare a set, its prefix is what gets pruned. If the publisher prefix is wider than what this repo should own, narrow it (prefix: your_orders.) rather than turning prune off — a narrowed boundary is provable, a remembered flag is not. prune: false on the set is the middle ground: still read and diffed, never deleted, and env-only rows are reported with a ! so you can see the drift.

Two things are never deleted regardless: a command outside every declared prefix (dverse never claimed it), and a command dverse could not create again — a GlobalHeader/Dashboard location or a first-party visibility rule. The second is reported with a ! and its reason, because an irreversible delete is not something to do quietly.

dverse commands pull captures what a set's prefix already manages:

bash
dverse commands pull                  # every configured set
dverse commands pull --set commands   # one of them

It writes one commands/{entity}/{uniquename}.yaml per live command and keeps the pull → plan exits 0 guarantee the other pulls do: nothing is emitted that the loader cannot read back identically. A command already declared in the set is left alone (the repo stays the source of truth), generated [Command] rows are skipped as owned by the attribute, and commands the platform will not let dverse author are skipped with the reason — those are exactly the ones prune leaves alone too. sequence is deliberately not captured: an undeclared sequence stays environment-owned, so pulling never makes dverse fight whoever reorders buttons in the designer.

Prune itself is per kind, so protecting one thing costs nothing elsewhere:

bash
dverse plan --no-prune               # keep every env-only component, every kind
dverse plan --no-prune=commands      # keep env-only commands; steps/APIs/web resources still prune

The valid kinds are steps (steps, images, orphaned plugin types, workflow activities), apis, webresources and commands; an unrecognised one is a hard error rather than a silent no-op. webresources: sets take the same per-set prune: false.

Three platform facts the diff has to work around:

  • context, contextvalue and location are create-only. A PATCH of those is accepted with 204 and then silently discarded, so changing one deletes and recreates the row — otherwise the plan would report the same change forever.
  • sequence is server-stamped and reads back as a decimal (100200010.0000000000), so an undeclared sequence is excluded from the diff entirely rather than defaulting to 0.
  • clienttype reads back null when unspecified, which is not the same as an empty set.

Only Form, MainGrid, SubGrid and AssociatedGrid can be created through the API; a GlobalHeader or Dashboard button is a hard error pointing you at ribbons:. So is a rules: or visibility: block, for the first-party reason above.

Classic ribbons (ribbons:) — the full RibbonDiffXml, for everything modern commanding cannot express: the application ribbon across every table, global header and dashboard command bars, hiding an out-of-box button, custom tabs and groups and flyouts, and every kind of enable/display rule.

yaml
    ribbons:
      - path: ribbons             # ribbons/{entity}.xml + ribbons/application.xml
        solution: MySolution      # optional; defaults to the environment solution

ribbons/account.xml is the <RibbonDiffXml> for account (the file stem is the logical name); ribbons/application.xml is the application ribbon, which applies org-wide. The file is the entire ribbon for that scope — the committed fragment wins, so a ribbon edited in the maker portal or Ribbon Workbench is overwritten on the next change. Ribbons are never pruned: there is no ownership prefix for a ribbon, so removing the file leaves the environment alone.

The platform stores one ribbon customization per scope, so committing ribbons/account.xml for a table that also has conditioned [Command]s does not make you choose: dverse folds the generated fragments into the committed one and deploys a single ribbon. Your file wins over the environment, never over the rest of your repo. Declaring an element the generator also emits — copying a generated <CustomAction> into the file to move the button, say — is a hard error naming both sides, because one of the two would otherwise be dropped.

Every reference inside a fragment is checked before anything is written: a <Button>'s Command, and a <CommandDefinition>'s EnableRule / DisplayRule ids, must resolve to a definition in the fragment, in ribbons/application.xml (which the platform merges into every table's ribbon), or to an out-of-box Mscrm.… id. This check exists because the platform imports a dangling reference without complaining — the button renders, clicking it does nothing, and every later plan is clean, since the stored XML matches what you committed exactly.

The plan is per element, not per file. Ribbons are stored decomposed across readable tables (ribboncustomizations, ribbondiffs, ribboncommands, ribbonrules, ribboncontextgroups), and the stored XML round-trips character-identical to what you committed modulo whitespace, attribute order and comments — so the plan says which button or rule changed rather than "the fragment changed". $webresource:…, {!EntityLogicalName} and $LocLabels:… tokens survive verbatim; ribbon XML resolves them natively and dverse does not touch them.

Writes are a staged solution import into a throwaway container (dverse_ribbon_stage, deleted afterwards), because every column on those tables is create-only — import is the only merge mechanism the platform offers. Entity ribbons publish through their entity's existing node; the application ribbon adds <ribbons><ribbon></ribbon></ribbons> to the same single PublishXml the rest of the apply uses.

Schema, forms, views, seed data, and apps deploy through dverse too — declared schema (schema: files, dverse schema adopt), committed formxml and savedquery files (forms: / views: sets, captured with dverse forms pull / dverse views pull), modern commands (commands: sets, captured with dverse commands pull), reference rows (seed:), and model-driven apps from committed customizations fragments (apps:). You still author forms, views and apps in the designer — then pull them into the repo and let plan/apply keep every environment converged. See solution-layout.md for how a whole repository composes. A single PublishXml covering exactly what the apply touched runs at the end — nothing else ever needs to call publish.

One client-side caveat for forms: model-driven apps cache form definitions in the browser (IndexedDB), so a correct publish can still look stale in an open session — a hard reload fixes it; nothing is wrong server-side.

8. CI recipe

yaml
- run: dotnet build -c Release
- run: dotnet test  -c Release --no-build
# drift gate: fail the pipeline when the environment doesn't match main
- run: dverse plan --assembly src/MyCompany.Plugins/bin/Release/net462/MyCompany.Plugins.dll --env prod
  env:
    DVERSE_TENANT_ID: ${{ secrets.DVERSE_TENANT_ID }}
    DVERSE_CLIENT_ID: ${{ secrets.DVERSE_CLIENT_ID }}
    DVERSE_CLIENT_SECRET: ${{ secrets.DVERSE_CLIENT_SECRET }}

Live integration tests fit the same pattern: gate each test on the presence of the environment variables (skip when unset) so the job is green with or without credentials, and only wire the secrets into runs that should hit the environment. Make the gate value load-bearing, too: the tests resolve their target from the committed dataverse.yaml (unless DVERSE_ENV overrides it), and before writing anything they verify DVERSE_TEST_URL names that same instance — a mismatch is a hard failure, not a skip, so a secret pointing at the wrong org can never arm a deploy against the committed default. A main-only job, serialized per environment:

yaml
integration:
  needs: build
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'   # PRs don't get secrets
  runs-on: ubuntu-latest
  concurrency: live-integration   # shared environment: one run at a time
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-dotnet@v4
      with:
        dotnet-version: 9.0.x
    - run: dotnet build
    - run: dotnet test tests/MyCompany.Plugins.Integration --no-build
      env:
        DVERSE_TEST_URL: ${{ secrets.DVERSE_TEST_URL }}       # skip gate + must equal the resolved env URL (cross-checked)
        DVERSE_TENANT_ID: ${{ secrets.DVERSE_TENANT_ID }}
        DVERSE_CLIENT_ID: ${{ secrets.DVERSE_CLIENT_ID }}
        DVERSE_CLIENT_SECRET: ${{ secrets.DVERSE_CLIENT_SECRET }}

Troubleshooting

Symptom Cause / fix
Public assembly must have public key token The plugin DLL isn't strong-named — add SignAssembly + snk (section 3).
Plugin assembly must be signed with valid certificate to associate to Managed Identity The assembly has no Authenticode signature — configure signing: (section 6, "Code signing and managed identity"). Strong-naming is not enough.
AADSTS700213: No matching federated identity record found The Azure federated credential's subject identifier doesn't match the signing certificate — re-run dverse sign info and compare, e.g. after rotating the certificate.
no registration manifest found Build first; the manifest is emitted at compile time into obj/. The Dverse.Sdk props enable this (EmitCompilerGeneratedFiles).
Assembly re-uploads although code didn't change Non-deterministic build. Check Deterministic, ContinuousIntegrationBuild, no wildcard versions, DebugType=none (set by Dverse.Sdk automatically).
Account / Account.Cols.X doesn't exist Entity or column missing from the snapshot — re-run dverse model sync with the right --entities and check the AdditionalFiles include.
DVERSE010 dataverse.snapshot.json is corrupt — re-run dverse model sync.
not signed in Run dverse auth login --env <name> once, or set the DVERSE_* service-principal variables.
Service principal gets 401 or Prv.../privilege errors The app registration authenticates but has no application user in that environment, or the app user lacks a sufficient role — create the app user and assign System Customizer or higher (section 4). A wrong DVERSE_TENANT_ID also surfaces as 401.
DVERSE_TEST_URL (...) does not match the resolved environment The integration tests' safety cross-check: the gate secret names a different instance than the one dataverse.yaml (or DVERSE_ENV) resolves. Fix whichever side is wrong — the tests refuse to deploy until they agree.
Step fired but image missing at runtime Someone deleted the image in the environment — any dverse apply restores it.