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:
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 from1. 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.csThis 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
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:
<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 (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) andDebugType=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
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:
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):
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: CrmAccountnamespace/classNamesrename what the generator emits (MyCo.Xrm.CrmAccountinstead ofDverse.Model.Account).[Step(typeof(CrmAccount), …)]andCrmAccount.Cols.*resolve as usual; references by the original schema name keep working too.languagepins 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 syncfalls 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:
// 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:
- Register the app — in the Microsoft Entra admin center, Applications → App 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.
- Create a client secret — Certificates & 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.
- Create the application user — in the Power Platform admin center, Manage → Environments → your environment → Settings → Users + permissions → Application 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:
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
IPluginentry 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 thectx.PreImage.CreditLimitaccess. 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
Keyon 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 nameA 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 / impersonatedIOrganizationService.ctx.Trace(...),throw ctx.Fail("message"),ctx.Shared(typed cross-step variables),ctx.UtcNow,ctx.Depth,ctx.CorrelationId;ctx.Raw/ctx.Servicesas escape hatches.
Step<Account> — the generic form
If you prefer the entity as a type argument, write it that way:
[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):
// 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:
// 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:
[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
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 changesPending 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.
dverse plan --assembly ... --json # raw CLI keeps exit codes: 0 = clean, 2 = changes, 1 = errorapply 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:
dverse adopt --assembly OldCompany.Plugins --out src/MyCompany.Plugins/Adopted.csRead-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:
<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 apluginpackagerow named{prefix}_{packageId}— the publisher prefix comes fromprefix:indataverse.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.Jsonis 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):
dverse sign initIt writes certs/plugins.pfx, prints a generated password once, adds *.pfx to .gitignore, and
prints the block to add to dataverse.yaml:
signing:
certificate: certs/plugins.pfx
password: ${DVERSE_SIGN_PASSWORD} # read from the environment — never a literal heresigning: 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:
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 tenantapply 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:
dverse sign info --env prodIt 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:
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):
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 columnThe 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?toOptional<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 JSONnull(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:
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:
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):
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:
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 parentEvery file under path (by extension: .js, .html, .css, .png, …) becomes {prefix}{relative path} — dist/forms/account.js → your_/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:
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 webresourcetypeMapped 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:
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 errors — ctx.FailLocalized resolves failure messages from .resx web
resources by the user's language:
[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:
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):
dverse pcf init --name CreditGauge # scaffolds pcfcontrols/CreditGauge
# or: --namespace MyCo --template dataset --path controls/CreditGauge --installThe 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:
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.jsonoutDir, defaultout/controls); - diff each control against the live
customcontrolrow — 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
ImportSolutionit — the same mechanismpac pcf pushuses, 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:
[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().parameterTypes — Xrm.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.
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)Conditions — EnableWhen = 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:
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# 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.requeueappaction 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: falseon 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:
dverse commands pull # every configured set
dverse commands pull --set commands # one of themIt 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:
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 pruneThe 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,contextvalueandlocationare 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.sequenceis 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.clienttypereads backnullwhen 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.
ribbons:
- path: ribbons # ribbons/{entity}.xml + ribbons/application.xml
solution: MySolution # optional; defaults to the environment solutionribbons/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
- 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:
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. |