D365 Web API quirks
Non-obvious Dynamics 365 CE / Dataverse platform behaviors that shaped crm's
implementation — each live-confirmed against a real org (on-prem v9.1 and/or
Dataverse online v9.2) and keyed to the issue that surfaced it. Read the
relevant section before touching a command in that domain; several of these
produce false-green mocked tests (the mock can't know the platform rejects
the shape), so they recur if rediscovered from scratch.
On-prem v9.x platform behaviors
1. appmodule (model-driven app) publish + read mechanics (#809)
A Web-API-created appmodule is Unpublished, and the plain appmodules
collection / by-id GET return published apps only — a fresh app reads back
"Does Not Exist". Four hard facts:
- Read the unpublished view via the collection function
GET appmodules/Microsoft.Dynamics.CRM.RetrieveUnpublishedMultiple()?$filter=…. The by-idappmodules(<id>)/…RetrieveUnpublished()is not bound to appmodule on on-prem v9.1 (errors "does not support entities of type 'appmodule'") even though MS Learn's bound-entities table lists it. All app reads (create read-back, resolve, find-live) go through RetrieveUnpublishedMultiple +$filter. PublishAllXmldoes NOT flip an appmodule to Published — only an app-scopedPOST PublishXmlwith<importexportxml><appmodules><appmodule>{id}</appmodule></appmodules></importexportxml>does.- Targeted
PublishXmlrunsValidateApp; an app with no bound sitemap fails with0x80050114("App does not contain Site Map"). Thesitemapnameunique == app.uniquenamelink is insufficient — the sitemap must be bound as an app component (type 62) viaAddAppComponents. - A bare app (no sitemap) is therefore unpublishable —
crm app create --publishkeepsPublishAllXml(surrounding customizations); the app-scoped publish lives in theapplyapps phase, gated on the app having a sitemap.ValidateApp(AppModuleId=<id>)(GET) is the pre-publish check.
2. Ribbon buttons are not API-creatable; unpublished diffs don't export (#142, #766)
Command-bar buttons live as <RibbonDiffXml> in customizations.xml; the only
path is export → edit XML → import → publish (crm ribbon automates it).
Additionally, an unpublished RibbonDiffXml is not carried by
ExportSolution (unlike forms/views, whose unpublished edits round-trip) — so
ribbon edits do not compose across separate verb invocations without a
publish between each; the empty-diff error says "publish first" (#766; real
compose fix tracked as a local working-copy flow, #773). hide-button differs:
it reads the live composed ribbon via RetrieveEntityRibbon, not the exported
diff.
On-prem also permits deleting a webresource still referenced by a published
ribbon button (online blocks it, 0x8004f01f), leaving a dangling
$webresource: ref that solution validate correctly rejects on the next
ribbon import. In shared-fixture teardown: ribbon remove the referencing
button before deleting the referenced webresource.
3. Solution-import dependency failures split into two classes (#182)
(a) Declared <MissingDependency> → faults loudly upfront (HTTP 500,
0x80048033), async and sync alike. (b) Undeclared dangling reference
(parent stripped from the zip, nothing declared) → imports as full success,
even via sync ImportSolution — dangling lookup created, every per-component
row success, importjob clean. No envelope fix detects class (b); only
post-import read-back verification can.
4. ImportSolutionAsync rejects ImportJobId and creates no importjob row on v9.1 (#182)
crm solution import auto-falls back to sync ImportSolution with the same
client GUID (envelope action:"ImportSolution", async_operation_id:null).
5. RemoveSolutionComponent takes a SolutionComponent entity-reference BODY (#181)
Not a ComponentId scalar param. Generalizes: Web API unbound actions
reject SDK-style scalar param names — check MS Learn for the action's real body
shape.
6. Business-rule activation via statecode PATCH is blocked server-side (0x80045002)
Platform constraint, not a CLI bug.
Both targets
7. Global option set retrieve must be a BARE GET (#179, #205)
GET GlobalOptionSetDefinitions(Name='…') with no $select/$expand —
$expand=Options → HTTP 400 on both targets, because the entity set is
base-typed and Options is a complex-type collection on the derived type,
not a nav property. The bare GET serializes the full derived type. Contrast:
attribute-metadata reads CAST to the typed attribute subtype and then
$expand=OptionSet,GlobalOptionSet — those ARE nav props. Rule: $expand nav
prop = OK; $expand complex-type collection = 400.
8. workflow rows: type 1=Definition, 2=Activation copy, 3=Template (#160, #165)
Activating spawns a type=2 copy sharing the definition's name
(parentworkflowid set only on the copy). Any list/group/dup-detect over raw
rows flags every activated workflow as a duplicate — operate on type=1 only
(crm/core/workflow.py::list_workflows already filters; reuse it). State-change
or delete on a type=2 GUID → 0x80045003 / 0x80045004.
9. Workflows/*.xaml in solution zips is often UTF-16 with BOM (#163)
Blind decode("utf-8") yields NUL-laden text and content regexes silently miss
everything — the feature no-ops on a real org while UTF-8 mocks pass. Decode by
BOM (solution_validate._decode_xaml); tests need a .encode("utf-16")
fixture. Applies to any serialized D365 export member. Also guard extracted
values against a GUID regex before interpolating into an OData $filter.
10. HTTP 412 is overloaded — not only an ETag conflict (#232)
A 412 body can carry a real D365 error code — notably 0x80060892
(alternate-key uniqueness violation). Apply the generic PreconditionFailed
label ONLY when the body yielded no code. Duplicate taxonomy (all distinct,
detect on the CODE): 0x80060892 alternate-key, 0x80040237
DuplicateRecord/SQL integrity, 0x80040333 duplicate-detection rule.
11. form clone collides on FormXml-INTERNAL GUIDs, not the root form id (#275)
Uniqueness is enforced across the form's internal registration GUIDs
(labelid, layout id, uniqueid, handlerUniqueId, libraryUniqueId) —
regenerate ALL braced form-internal GUIDs with a consistent old→new mapping per
clone, but never rewrite classid (fixed control-type platform constants),
Role Id, ViewId, or QuickFormId.
12. update-relationship must PUT-replace the UN-cast path (#267)
RelationshipDefinitions(<MetadataId>) — the cast subtype path returns 405 on
both targets. The PUT body MUST carry the @odata.type discriminator, but a
minimal-metadata cast GET can omit it — inject it during retrieve-merge-write.
Contrast quirk 7: optionset/attribute READS cast then expand; relationship
WRITES go to the un-cast set.
13. Polling importjobs(<id>) online can transiently return 0x80040217 before the row commits (#276)
A race, not a real failure. Classify via classify_d365_error
(0x80040217 → not_found regardless of HTTP status) and tolerate/retry
not_found — a narrow status == 404 check misses the code riding a non-404
status. (0x80040217 is the generic "does not exist" code — it also surfaces
as the "solution unique name not valid" 404.)
14. RetrieveTotalRecordCount keys on the entity LOGICAL name (#314)
The CLI resolves user input (logical OR set name, case-insensitive) through the
cached NameMap.resolve before the call. Reuse pattern: a verb whose backing
API wants a logical name resolves through NameMap, never pushes the
distinction onto the user. Still a server-CACHED count; query odata --count
is the live path.
15. $metadata (CSDL) is served as XML only (#266)
The default Accept: application/json makes Dataverse answer HTTP 415 on both
targets. Any CSDL fetch must override Accept: application/xml for that single
GET (crm/core/metadata.py::_fetch_csdl).
16. Not every SDK message is a Web API action (#435 → #453, ADR 0003)
Some messages are SOAP-only and absent from the Web API entirely (e.g.
ConvertDateAndTimeBehavior: zero CSDL hits, action invoke → "Resource not
found for the segment", no MS Learn webapi reference page). Before building a
command around an SDK message, grep the live CSDL (metadata list-actions /
list-functions). Contrast RetrieveMetadataChanges — a real unbound Function
(powers metadata changes, #455) taking the EntityQueryExpression as raw
un-quoted JSON via parameter alias.
17. Web API pluginassemblies create does NOT auto-create plugintype rows (#506, #517)
Only the Plug-in Registration Tool reflects client-side and creates them; a raw
upload of a valid signed IPlugin leaves zero plugintypes. To register a step
you must POST plugintypes explicitly (typename + friendlyname +
pluginassemblyid@odata.bind; publickeytoken/version/culture are
read-only, derived from the bound assembly). Online forces sandbox isolation
(mode 2), which validates the registered publickeytoken against the uploaded
content — supply the DLL's real strong-name token, never the "null" default.
18. The workflow-create wall (0x80045040) is XAML-PROVENANCE-sensitive, not target-sensitive
Live-confirmed identical on both targets (2026-06-24): a raw POST workflows
with hand-authored/foreign XAML → 0x80045040 on both; crm workflow
clone (reusing an existing workflow's real designer XAML) creates a draft
successfully on both, including online. The platform gate accepts XAML it
recognizes as designer-produced — it is NOT "cloud blocks all create". MS docs
saying "XAML create supported on-prem, not with Dataverse" refer to the SOAP
SDK + hand-authored-XAML case. Cloning a system workflow still 500s
(0x80040216, internal-GUID collision — quirk 11's class). Feasibility doc:
docs/superpowers/specs/2026-06-24-workflow-create-update-feasibility.md.
19. workflow update: published-edit lock, no triggeronupdate column, type=2 orphans (#538, ADR 0013)
- Editing an ACTIVATED definition →
0x80045002("Cannot update a published workflow definition") — the generic can't-mutate-a-published-process gate (same code as quirk 6).update_workflowtries the direct PATCH first and only on0x80045002drives deactivate → edit → reactivate. - There is NO
triggeronupdateboolean: the writable trigger columns aretriggeroncreate,triggerondelete(bools), andtriggeronupdateattributelist(memo; non-empty = on-update enabled). - On-prem, deactivating does NOT delete the type=2 activation copy, and
deleting the type=1 parent does not cascade — an orphaned type=2 row is
UI-only deletable (
0x80045004). E2e consequence: throwaway-clone tests never activate.workflow_scopeenum: 1=User, 2=BusinessUnit, 3=Parent:Child BUs, 4=Organization.
20. Security-role privileges: unbound read; the baseline can't be stripped (#551)
- Read via the UNBOUND
GET RetrieveRolePrivilegesRole(RoleId=<guid>)— the bound form 404s. On-prem returns noPrivilegeName(only Depth + PrivilegeId). PrivilegeDepth: Basic=0/Local=1/Deep=2/Global=3/RecordFilter=4 (serialized as member NAME; normalize int|str). - Every role auto-gets immovable baseline privileges (the SharePoint
document-management set) that
ReplacePrivilegesRolecannot remove — a strict set-equality reconcile never converges.applyuses subset-satisfaction (skip when every declared privilege is present at declared depth); a removal-only spec change is a no-op.
21. A Create-message entity image must set messagepropertyname="Id", not "Target"
The platform rejects a Create image keyed on Target. MS Learn's
image-message table conflates the request property (Target) with the
image property (Id for Create) — verify against a live registration, not
the table. Only a post-image is valid on Create. All other messages: image
property == request property.
22. MSCRM.SolutionUniqueName is ADDITIVE, not steering (ADR 0020, #636)
Every new customization component is ALWAYS added to the system Default
Solution; the header only ALSO files it into the named unmanaged solution. A
component created with no solution context lands only in the Default Solution —
orphaned / not exportable. Design consequences (explicit --solution required
for customization writes) are recorded in ADR 0020.
23. WhoAmI is privilege-free — a green whoami is a FALSE all-clear
A principal with NO security role still gets exit 0 from connection whoami
(returns UserId/OrgId), while every data op fails "has not been assigned any
roles". Role assignment for an S2S app user is a mandatory manual admin step
(the roleless principal cannot self-grant). Any org-bootstrap/runbook VERIFY
step must be a write (entity CRUD roundtrip), never just whoami.
24. Async BulkDetectDuplicates logs flagged records, not A↔B pairs (#710)
Each duplicaterecord row has _baserecordid_value set but
_duplicaterecordid_value (the matched-counterpart lookup) left NULL —
live-confirmed on both targets; duplicateid is only the log row's own PK.
dup bulk-detect --wait surfaces _baserecordid_value and never presents
duplicateid as "the record it duplicates". Trap for readers:
$select=_duplicaterecordid_value is a valid select (doesn't 400) but returns
null on bulk-job rows — canned mocks lie about population; confirm against a
live raw GET.