A field changed and nobody knows why
A column holds a value nobody meant to put there. Here is how to work back from the symptom to the component responsible, using what the platform already gives you, and where that trail goes cold.
The report is always roughly the same. A value is wrong on some records and right on others, or it was right last week, or it goes back to the same number every time someone fixes it. Nobody changed anything, except that a release went out on Thursday.
There is a method for this. It is mostly elimination, because of something worth understanding before you start.
The platform gives you two halves that do not join. Audit history knows when a value changed and whose context it changed in. Component metadata knows what exists. Nothing maps a timestamp onto a component.
Everything below is about closing that gap by hand.
Start with the record, not the code
Open an affected record and look at its audit history. If it is there, you get the old value, the new value, a timestamp, and a principal the change was attributed to. That is your anchor, and everything afterwards narrows from it.
Auditing has to be enabled in three places for a column to show up: at the organization level, on the table, and on the column itself. Any one of them off and the history is silently empty. If that is where you are, skip to what to turn on now, fix it, and accept that this particular incident will be diagnosed the slow way.
Two details from the history matter more than the values themselves. The exact timestamp, because you will compare it against deployment times. And whether the changes cluster: one record at one moment is a different problem from four hundred records inside the same second, which is an integration or a bulk operation rather than a person.
What the attributed user actually means
This is where most investigations go wrong, so it is worth being precise.
The principal on an audit row is the security context the write executed in. It is not a record of which component performed the write, and the two come apart constantly.
| Attributed to | What that narrows it to |
|---|---|
| A named human | A form save, a canvas app, a bulk edit, an import. Also, and this is the trap, any plugin step that fired on that person's save. Plugin steps run in the calling user's context unless impersonation is configured, so the plugin's write wears the user's name. |
| An application user | An integration writing over the API, or a cloud flow acting through its connection. You have narrowed it to the identity, not the code, and one application user often fronts several integrations. |
| A workflow or flow owner | A classic workflow or a modern flow running as its owner. If the owner left the company two years ago and the automation still runs, this is often the first time anyone notices. |
| SYSTEM | Platform-performed work: rollup recalculation, cascade behaviour, a calculated column refreshing, some Microsoft-internal operations. |
The practical consequence: an audit row attributed to a person who swears they did not touch the field is usually telling the truth. Something ran inside their save.
Narrow by time
You have a timestamp. The next question is what changed shortly before it.
- Solution import history. Every installed solution carries an install date, so the ones that landed in the days before the symptom are your first candidates. This is the “we pushed something on Thursday” step, and it is worth doing even when everyone is confident the release is unrelated.
- Modified dates on the components themselves. Flows, classic workflows, web resources and plugin assemblies all carry one. A flow edited the previous afternoon is a stronger candidate than one untouched for a year.
- Configuration and data changes that are not deployments. A new record in a config table, a changed environment variable, a connection re-authenticated as a different user. These leave no solution trail and cause a surprising share of incidents.
If the symptom started without any deployment, that is informative rather than a dead end. It usually means something ran that had always been able to run, and the data finally met its condition.
Narrow by what is bound to the table
Now list everything that could act on the table at all. You want the candidate set, not the answer yet.
- Plugin steps registered on the table, with their message, stage, execution order and filtering attributes. A step whose filter includes your column is reacting to it, which is a different thing from writing it, and both are relevant.
- Classic workflows, dialogs and actions with the table as their primary entity. Check the state: only activated ones matter, and an activated workflow nobody remembers activating is a common culprit.
- Modern flows with a Dataverse trigger on the table. Pay attention to whether the trigger declares filtering columns at all, because one that does not fires on every change to every column.
- Business rules scoped to the table, which can set values on form load without anyone touching a control.
- Attribute mappings from a parent table, which populate columns on create and are invisible from the child record.
- Calculated and rollup columns, where the platform itself is the writer.
Cross this candidate list against the timestamp and the attributed principal, and it usually collapses to two or three suspects. Working out which of them writes the specific column is the sweep in the manual writers guide.
The patterns that usually turn out to be responsible
After enough of these, the same shapes keep coming back. Checking for them directly is often faster than working the method in order.
The value comes back after you fix it
Something is rewriting on a trigger you are also satisfying when you correct the record. The classic version is a plugin step that writes a column its own trigger filters on, which re-queues itself on every save until the platform's depth limit stops it.
The value is different depending on who saved
Two components both write the column, and which one wins depends on execution order. Two plugin steps registered at the same order on the same message and stage have no defined winner, and the result can flip after a re-import.
It fires when nothing relevant changed
A flow trigger or an update step with no column filter runs on every change to the row. Add autosave and a form with twenty fields, and it runs constantly. In a test solution we built deliberately messy, the trigger looked like this, with no filteringattributes parameter anywhere in it:
Only new records are wrong
Look at attribute mappings from the parent table and at anything registered on Create rather than Update. Values copied down on create do not reappear in any update path, so they are easy to miss when you are watching saves.
It started when somebody left
Automation owned by a disabled account behaves inconsistently rather than stopping cleanly. Worth checking the owner of every candidate flow and workflow whenever the timing lines up with someone's departure.
Getting runtime evidence
Everything so far is inference from structure. To confirm a suspect you want a record of the thing actually running.
- Flow run history is the best evidence the platform offers. Each run shows its trigger inputs and the inputs and outputs of every action, so you can see the exact value written and what it was computed from. Retention is limited, so pull this early.
- The plugin trace log records plugin execution, but only if tracing was enabled before the incident, and it is purged aggressively. Enable it now if you expect a recurrence.
- A sandbox reproduction is slower and more reliable than either. Restore a copy, deactivate your prime suspect, and see whether the symptom survives. Deactivating one component at a time is crude, and it produces a definite answer.
Pull flow run history before you spend a day on structure. It expires, and structure does not.
Where the trail goes cold
Three places, and it is better to recognise them early than to keep pulling.
- Auditing was off for the column. You have no anchor, so there is no timestamp and no attributed principal to narrow with. The investigation becomes a pure structural sweep of every writer.
- The write came from outside the platform. An external system calling the Web API is attributed to its application user and leaves nothing else behind inside Dataverse. The rest of that investigation happens in the other system's logs.
- A plugin whose source nobody has. You can see the step registration and that it ran. Which columns its compiled code writes is not readable without the source, and if the column name is built at runtime it is not statically knowable at all.
Reaching one of these is a real outcome rather than a failure. The useful move is to say so, write down what you ruled out, and turn on what you need for the next occurrence.
What to turn on now
Most of what makes these investigations painful is a setting somebody could have flipped earlier. If you are reading this mid-incident, the incident is already expensive; the next one does not have to be.
- Auditing on the columns that matter, at all three levels. Sensitive and business-critical columns first rather than everything, since retention costs storage.
- Filtering attributes on every update plugin step, and filtering columns on every Dataverse flow trigger. This narrows blame at diagnosis time and cuts a large amount of pointless execution.
- Explicit, distinct execution orders on plugin steps that write the same column, so the winner is a decision rather than an accident.
- Dedicated application users per integration rather than one shared account. Attribution is only useful when the identity names one thing.
- Owners on automation that are service accounts rather than people.
The full structural sweep, by hand →
Why the platform's dependency check will not shortcut this →