Working on a custom multi-entity Business Process Flow (Opportunity → Quote) recently, we ran into this error while trying to reactivate the process:
The BPF (custom_salesprocess) had already traversed several stages — Generate Opportunity → Qualify → Generate Quote → Initiate Deposit/Bank Guarantee → Sales Completion — before landing on PM Completion, which sits in the “Close” category.
Reactivating the process at that point is where it failed.
In a multi-entity BPF, each stage is bound to a specific entity via processstage.primaryentitytypecode, and the BPF instance record carries a lookup column per non-primary entity used in the flow (e.g. bpf_opportunityid, bpf_quoteid). When a stage tries to activate, Dataverse resolves the “participating entity” for that stage — and if the corresponding lookup on the instance is null, or points to a record that no longer exists (or the user can’t read), we get exactly this error.
Diagnosis
First, we check for the processtageid we got in the error – 0642302a-cc72-4d31-8e08-66d311f6f7b1
SELECT processstageid, primaryentitytypecode
FROM processstage
WHERE processid = '0d680f7c-6c3a-ef11-a316-00224896a8d5'
Then we confirm what entity each stage actually maps to using the processid:
PM Completion resolves to Quote (1084) — not Opportunity — which meant the BPF instance needed a valid bpf_quoteid to activate it.
Checked the instance directly:
SELECT * FROM custom_salesprocess WHERE businessprocessflowinstanceid = '682f0586-3660-f111-a826-00224892607c' or bpf_opportunityid = '9e5bdc52-62d4-4773-8d30-fba2089ceba2'
bpf_quoteid and bpf_quoteidname both came back NULL — despite an active Quote already existing against the Opportunity. Something along the way (likely the Quote being created outside the BPF’s native “generate related record” flow) never linked it back.
Fix
A quote existed, so it was a matter of linking it. The preferred route is the related-entity picker on the BPF stage bar in the Opportunity form (safest, goes through supported platform logic). Where that’s not viable, updating the lookup directly works:
UPDATE custom_salesprocess SET bpf_quoteid = '18cc66e2-9280-f111-ab0f-7c1e528a7a2a' WHERE businessprocessflowinstanceid = '682f0586-3660-f111-a826-00224892607c';
After the update, the process reactivated cleanly.
We got the options to Abandon and Finish the flow.
Rules of thumb
Multi-entity BPF errors on activation → check the stage’s primaryentitytypecode first. If it’s not the primary entity you’re working from, the instance needs a valid lookup to that other entity.
The instance table always carries one lookup column per entity in the flow (bpf_<entity>id) — go straight to that table and check for nulls before looking anywhere else.
A null lookup with a record that already exists usually means the record was created outside the BPF’s intended path (manually, via integration, or via a process that bypasses the stage’s native record-generation step) and never got linked back.
Before manually setting the lookup, confirm the target record’s statecode/statuscode — a Won/Closed or inactive record may satisfy the lookup but still fail stage entry criteria if a business rule checks for an active state.
Prefer the BPF’s own related-entity picker on the form over a direct table update where available — it’s the supported path and won’t skip any platform-side validation.
Key takeaway
“Participating entity record of stage: X is not valid (0x80040216)” on a multi-entity BPF almost always means the stage’s bound entity has no valid linked record on the BPF instance. Trace the stage to its entity via primaryentitytypecode, check the corresponding bpf_<entity>id lookup on the instance, and link a valid, active record.
We recently refactored a large JavaScript solution — replacing a number of deprecated methods with their supported equivalents, along with some performance improvements — the kind of change that touches a lot of files without changing what any of them are actually supposed to do. That solution needed to go out to three Production environments, and before rolling it out, we wanted to be sure of one thing: were all three Prod environments currently running the same JavaScript to begin with? If one of them had quietly drifted from the others over time — a direct hotfix, a missed deployment — we wanted to know that before deploying, not after.
That’s what led us to write this utility. And while our own case was three Prod environments, there’s nothing about it that ties it specifically to Dev/UAT/Prod — that’s just the most common shape for this kind of check. The source is really just “the environment whose solution I want to treat as the baseline,” and the target(s) are “however many environments I want to check that baseline against.” You could just as easily point the source at UAT and compare it against multiple Staging / DM / UAT instances, or point it at Prod and verify a DR/failover environment matches — any number of targets can be listed, and each is compared against the source independently, so you always know exactly which environment(s) are out of step rather than just “something, somewhere, differs.”
To keep this post simple, we’ll walk through a smaller example here — a Dev environment with two solutions that between them contain a handful of JS web resources — and compare it against UAT and Prod to confirm all three environments.
The approach
Rather than matching components by name across environments, we used the solutioncomponent entity to pull the exact web resource GUIDs belonging to our solution from the source environment (Dev). This matters because a component’s GUID stays the same wherever it travels via solution import — so the same GUID can be looked up directly in every other environment, with no name-matching guesswork involved.
For each web resource, in each environment:
Retrieve the content field (base64-encoded) and decode it.
Compute a SHA-256 hash of the decoded bytes.
If two environments produce the same hash for the same GUID, the files are guaranteed byte-identical. If the hashes differ, something changed — even a single character is enough to produce a completely different hash.
Sample Code –
private const int COMPONENTTYPE_WEBRESOURCE = 61;
private const int WEBRESOURCETYPE_JSCRIPT = 3;
private static List<Guid> GetJavaScriptWebResourceIds(IOrganizationService svc, List<string> solutionUniqueNames)
{
var solutionQuery = new QueryExpression("solution")
{
ColumnSet = new ColumnSet("solutionid")
};
solutionQuery.Criteria.AddCondition("uniquename", ConditionOperator.In,
solutionUniqueNames.Cast<object>().ToArray());
var solutionIds = svc.RetrieveMultiple(solutionQuery).Entities
.Select(s => s.Id).Cast<object>().ToArray();
var compQuery = new QueryExpression("solutioncomponent")
{
ColumnSet = new ColumnSet("objectid")
};
compQuery.Criteria.AddCondition("solutionid", ConditionOperator.In, solutionIds);
compQuery.Criteria.AddCondition("componenttype", ConditionOperator.Equal, COMPONENTTYPE_WEBRESOURCE);
var allWebResourceIds = svc.RetrieveMultiple(compQuery).Entities
.Select(e => (Guid)e["objectid"])
.Distinct()
.ToArray();
// The Web Resource component type covers every kind of web resource in the solution -
// JS, HTML, CSS, PNG, SVG, and so on - not JavaScript specifically. We only want the
// actual scripts, so we filter again on the webresource entity's own type field.
var wrQuery = new QueryExpression("webresource")
{
ColumnSet = new ColumnSet("name")
};
wrQuery.Criteria.AddCondition("webresourceid", ConditionOperator.In, allWebResourceIds.Cast<object>().ToArray());
wrQuery.Criteria.AddCondition("webresourcetype", ConditionOperator.Equal, WEBRESOURCETYPE_JSCRIPT);
return svc.RetrieveMultiple(wrQuery).Entities
.Select(e => e.Id)
.ToList();
}
private static string GetContentHash(IOrganizationService svc, Guid webResourceId)
{
var e = svc.Retrieve("webresource", webResourceId, new ColumnSet("content"));
var bytes = Convert.FromBase64String((string)e["content"]);
using (var sha = SHA256.Create())
{
var hash = sha.ComputeHash(bytes);
return BitConverter.ToString(hash).Replace("-", "");
}
}
Running this against Dev + UAT + Prod, for every JavaScript web resource GUID found, gives a simple report per environment: MATCH, MISMATCH, or MISSING.
Going a step further — showing what actually changed
A MISMATCH on its own only tells you that something differs, not what. So we extended it to also diff the two files line by line whenever the hashes didn’t match, using a classic LCS (Longest Common Subsequence) based diff — the same underlying idea git diff uses.
private static List<string> DiffTextLines(string baseline, string other, int maxDiffs = 6)
{
var a = baseline.Replace("\r\n", "\n").Split('\n');
var b = other.Replace("\r\n", "\n").Split('\n');
int n = a.Length, m = b.Length;
var dp = new int[n + 1, m + 1];
for (int i = n - 1; i >= 0; i--)
for (int j = m - 1; j >= 0; j--)
dp[i, j] = a[i] == b[j] ? dp[i + 1, j + 1] + 1 : Math.Max(dp[i + 1, j], dp[i, j + 1]);
var diffs = new List<string>();
int x = 0, y = 0;
while (x < n && y < m && diffs.Count < maxDiffs)
{
if (a[x] == b[y]) { x++; y++; continue; }
if (dp[x + 1, y] >= dp[x, y + 1]) { diffs.Add($"line {x + 1} removed: \"{a[x]}\""); x++; }
else { diffs.Add($"line {y + 1} added: \"{b[y]}\""); y++; }
}
return diffs;
}
This walks a dynamic-programming grid to find the longest sequence of lines common to both files. Everything outside that common sequence is, by definition, either a removed line or an added one — which is exactly what shows up in the report instead of a plain “files differ”.
Every result also gets written out to a CSV, one row per web resource, with a hash column per environment, a Status column, and a DiffSummary column showing the actual line-level differences for anything that doesn’t match.
How it works, end to end
Connects to the source environment (Dev) and reads solutioncomponent for the solution’s unique name, filtered to web resources only.
For each web resource GUID found, fetches that same GUID from every target environment listed (UAT, Prod, or as many as you configure).
Hashes the content from each environment and compares every target’s hash against the source’s hash — independently, so you know exactly which target(s) differ, not just that “something” differs.
For anything that doesn’t match, runs the LCS diff and records the specific lines that changed.
Writes a CSV report and saves every environment’s actual file content to disk, so any mismatch can be opened directly in a diff tool if the summary line isn’t enough.
Using it yourself
The console app is config-driven — no code changes needed to point it at your own solution and environments. config.json looks like this:
SolutionNames — the unique name(s) of the solution(s) holding your web resources. You can list more than one, as in the example above, if your JS is spread across a couple of solutions.
Source — the environment you trust as the baseline (usually Dev).
Targets — as many environments as you want to check, each compared independently against Source.
Once config.json is filled in, just run:
jscompare-fx.exe config.json
and the report and diffed files show up under the configured output folder.
Recently, we ran into an interesting issue while working with a Dynamics 365 / Dataverse environment. Even though the user was assigned the System Administrator security role, the environment did not appear in either the Power Platform Admin Center or the Maker Portal. The surprising part was that nothing was actually wrong with the permissions. The environment eventually appeared automatically after about 4 to 6 hours, which suggests there was a synchronization delay. Another interesting aspect was that, out of 3 environments assigned to the user, only 1 had this problem.
The problem
After receiving System Administrator access, we expected the environment to appear immediately in:
• Power Platform Admin Center (PPAC) • Power Apps Maker Portal
However, the environment simply wasn’t listed, even though we could access the Dynamics 365 application directly using its URL.
Open the Maker experience from Advanced Settings
If you already have the Dynamics 365 / CRM URL for the environment, you can continue working without waiting for the synchronization.
Navigate to:
Advanced Settings → Settings → Customize the System
Then select the Try New Experience option.
Selecting Try New Experience opens the modern Maker experience for that environment even when it isn’t listed in the environment selector.
Open the environment directly in PPAC or Maker Portal using the Environment ID
We can also open the environment directly in Power Platform Admin Center or Maket Portal by using the Environment ID.
The Environment ID can also be found in the environment details page. We can use the Environment ID and the corresponding Maker and PPAC URL to open the particular environment.
This time, instead of just explaining the root cause again, we put together a quick reference table for converting an Advanced Find date range into the correct SQL4CDS UTC boundary, for any user time zone.
The Scenario
Advanced Find query on Work Orders, filtered on Created On:
On or After 01/01/2026
On or Before 05/01/2026
The user running this is in Auckland, New Zealand. (User’s Time Zone is Auckland)
We were running SQL4CDS in UTC mode. A query that simply matches the literal date strings against createdon will not reliably reproduce the Advanced Find count, because Advanced Find evaluates the date range in the user’s local time zone, while createdon is stored in UTC. The two only line up once the date range is converted to explicit UTC boundaries.
The Reliable Formula
StartBoundaryUTC = StartDate 00:00:00 (user’s local time) → converted to UTC
EndBoundaryUTC = (EndDate + 1 day) 00:00:00 (user’s local time) → converted to UTC
WHERE createdon >= StartBoundaryUTC AND createdon < EndBoundaryUTC
Two points worth calling out:
The upper boundary always uses End Date + 1 day, with a strict <, not <=. This avoids any ambiguity around milliseconds and reliably captures the entire end date.
For time zones ahead of UTC (New Zealand, India), convert by subtracting the offset. For time zones behind UTC (Hawaii, US), convert by adding the offset.
Quick Reference Table
Boundary used below: 1/01/2026 to 5/01/2026 (end boundary = 6/01/2026 local, converted to UTC).
Time Zone
Offset
DST Active?
Start Calculation
End Calculation
SQL4CDS Boundary
UTC
+0:00
No DST
2026-01-01T00:00 − 0:00
2026-01-06T00:00 − 0:00
>= ‘2026-01-01T00:00:00Z’ AND < ‘2026-01-06T00:00:00Z’
India (IST)
+5:30
No DST
2026-01-01T00:00 − 5:30
2026-01-06T00:00 − 5:30
>= ‘2025-12-31T18:30:00Z’ AND < ‘2026-01-05T18:30:00Z’
New Zealand (NZDT – summer)
+13:00
Yes (active in Jan)
2026-01-01T00:00 − 13:00
2026-01-06T00:00 − 13:00
>= ‘2025-12-31T11:00:00Z’ AND < ‘2026-01-05T11:00:00Z’
New Zealand (NZST – winter)
+12:00
Yes (inactive in Jan)
2026-01-01T00:00 − 12:00
2026-01-06T00:00 − 12:00
>= ‘2025-12-31T12:00:00Z’ AND < ‘2026-01-05T12:00:00Z’
Hawaii (HST)
−10:00
No DST
2026-01-01T00:00 + 10:00
2026-01-06T00:00 + 10:00
>= ‘2026-01-01T10:00:00Z’ AND < ‘2026-01-06T10:00:00Z’
Since January falls in NZ summer, the Auckland user’s boundary above uses NZDT (+13:00):
SELECT count(1)
FROM msdyn_workorder
WHERE createdon >= '2025-12-31T11:00:00Z'
AND createdon < '2026-01-05T11:00:00Z'
This reproduces the Advanced Find count for the same range.
NZ DST Transition Windows
New Zealand does not stay on a single offset year-round, so the correct value depends on the date range being queried, not the date the query is run.
Period
Offset
Late Sep – early Apr (NZDT)
UTC+13
Early Apr – late Sep (NZST)
UTC+12
Confirm the exact transition dates for the specific year, as they shift slightly.
Rules of Thumb
Rule
Reason
End boundary = End Date + 1 day, use < not <=
Captures the full end date without truncating time
Time zones ahead of UTC: subtract the offset
UTC = Local − Offset
Time zones behind UTC: add the offset
UTC = Local + Offset
Check DST for the query dates, not today’s date
The same time zone can have two different offsets depending on the time of year
Key Takeaway
When running SQL4CDS in UTC mode, the reliable and repeatable approach is to convert the Advanced Find date range into explicit UTC boundaries using the local-time offset (accounting for DST where applicable), and query using >= / < against those boundaries.
Reference
For more background on how SQL4CDS interprets date and time values in UTC vs Local mode, see Mark Carrington’s article: Date/Time handling in SQL 4 CDS
One of our recent requirements was to ensure that users could no longer register for event sessions once the session had already ended. The goal was to improve the user experience by hiding expired sessions from the Event Registration form, rather than displaying sessions that were no longer available. Once a session’s end date and time had passed, it should be hidden from the UI so that new registrations could not be made through the form.
We used the standard ‘Default registration form with Sessions’ provided by Microsoft and added a small JavaScript customization. The script executes after the form loads by subscribing to the d365mkt-afterformload event. It reads the rendered session date and end time, creates a JavaScript Date object, compares it with the current browser time (all users are in the New Zealand time zone), and hides any expired sessions. If every session has expired, the entire Sessions section is also hidden.
We can see the following sessions configured for the event.
Below we can see the Event Registration form showing all the sessions before it is rendered for the end users.
And after our JavaScript that is registered on d365mkt-afterformload runs, it hides all the expired sessions except the active one.
JavaScript
document.addEventListener("d365mkt-afterformload", function () {
document.querySelectorAll(".eventSession").forEach(function (session) {
debugger;
const values = Array.from(
session.querySelectorAll(".msdynmkt_personalization")
).map(x => x.textContent.trim());
// [0] Session Title
// [1] Session Date (M/D/YYYY)
// [2] Start Time
// [3] End Time
// [4] Location/Room (optional)
if (values.length < 4) {
return;
}
const dateText = values[1];
const endTimeText = values[3];
const dateParts = dateText.split('/');
if (dateParts.length !== 3) {
return;
}
const month = parseInt(dateParts[0], 10) - 1;
const day = parseInt(dateParts[1], 10);
const year = parseInt(dateParts[2], 10);
const timeMatch = endTimeText.match(/(\d+):(\d+)\s*(AM|PM)/i);
if (!timeMatch) {
return;
}
let hours = parseInt(timeMatch[1], 10);
const minutes = parseInt(timeMatch[2], 10);
const meridian = timeMatch[3].toUpperCase();
if (meridian === "PM" && hours !== 12) {
hours += 12;
}
if (meridian === "AM" && hours === 12) {
hours = 0;
}
const sessionEndDateTime = new Date(
year,
month,
day,
hours,
minutes,
0
);
if (sessionEndDateTime <= new Date()) {
session.style.display = "none";
}
});
// Hide the entire Sessions block if all sessions are hidden
const visibleSessions = Array.from(
document.querySelectorAll(".eventSession")
).filter(s => s.style.display !== "none");
if (visibleSessions.length === 0) {
const sessionBlock =
document.querySelector('[data-editorblocktype="Sessions"]') ||
document.querySelector("fieldset.eventSessions")?.closest("div");
if (sessionBlock) {
sessionBlock.style.display = "none";
}
}
});
Things to Note
All users were in the New Zealand time zone, so browser time could be safely used for comparison.
The solution targets the standard out-of-the-box Event Registration form with Sessions.
While optimising the performance of a Dataverse plugin, we noticed a QueryExpression using ColumnSet(true). The business logic only required a few attributes, so replacing ColumnSet(true) with a minimal ColumnSet looked like an easy performance improvement. The query also used Distinct = true, which we left unchanged because it had always been there and everything was working correctly.
The Original Query
query.ColumnSet = new ColumnSet(true);
query.Distinct = true;
We changed the query to retrieve only the attributes required by the business logic:
query.ColumnSet = new ColumnSet(
"msdyn_systemstatus",
"msdyn_datewindowstart",
"custom_cancelledreason");
query.Distinct = true;
The optimisation looked perfectly valid. The query returned the expected records, but part of the plugin logic suddenly stopped working.
The Unexpected Bug
The plugin compared Work Orders using Entity.Id to determine whether a record already existed in a collection. During debugging, we discovered that every retrieved entity had Guid.Empty as its Id, causing the comparison logic to fail and duplicate records to be added.
Finding the Root Cause
To isolate the problem, we reproduced the behaviour with a simple Lead query.
QueryExpression query = new QueryExpression("lead");
query.ColumnSet = new ColumnSet("lastname");
query.Distinct = true;
Once again, the record was returned successfully, but Entity.Id was Guid.Empty.
While reading the Microsoft documentation for QueryExpression.Distinct, we found the following remark:
“When the Distinct property is true, the results returned don’t include primary key values for each record because they represent an aggregation of all the distinct values.”
The Fix
Including the primary key in the ColumnSet resolved the issue.
query.ColumnSet = new ColumnSet("leadid", "lastname");
query.Distinct = true;
After this change, both Entity.Id and the leadid attribute were populated correctly.
One More Observation
While investigating the issue, we realised something else. The original query used ColumnSet(true) together with Distinct = true. Since ColumnSet(true) retrieves every readable attribute, including the primary key, every record is already unique because the primary key itself is unique. In that particular QueryExpression there were no LinkEntity joins or other scenarios that could naturally produce duplicate rows. That meant Distinct = true was not really providing any value. In fact, once we reviewed the query, removing Distinct = true was a cleaner solution than simply adding the primary key back into the ColumnSet. This serves as a useful reminder that performance optimisation is not just about reducing the columns retrieved. It is also a good opportunity to question whether every part of the original query is still necessary.
Lessons Learned
• Replacing ColumnSet(true) with a minimal ColumnSet is a good optimisation. • If a query uses Distinct = true and the code relies on Entity.Id, include the primary key in the ColumnSet. • Review whether Distinct = true is actually required. In many QueryExpression scenarios, especially those without joins, it may be redundant. • Small performance improvements can sometimes expose subtle behaviours that are easy to overlook.