
Every automated control writes two things: the change it makes, and the record that says it made it. Most of the time those agree. This post is about the run where they didn’t, and about the habit that keeps them honest.
If you’re joining here, the first post describes the system. Membership of one Entra group drives Purview legal holds through two scheduled Power Automate flows and the Microsoft Graph eDiscovery (Premium) API. Each person gets one hold with two sources, their mailbox and their OneDrive, and a content query whose date range runs from the join date to 2999-12-31. When they leave, the Remove flow rewrites that end date. The query is the hold’s own record of when the person was in scope.
That last sentence is why this post matters. A hold with no query can’t be closed, because there’s no end date to rewrite. The person stays on hold indefinitely, and nobody notices until someone asks legal why a hold is still capturing mail from a person who left months ago.
The symptom
On a newly created hold, the step that attaches the mailbox, Add_UserSource, ran through its five-minute retry and still failed. It reported a 500 after 6m22s, with an Exchange unified-policy deployment error. The flow did what we’d built it to do with a failed step: it stopped that branch and reported a failure.
So far that looks like a control working as designed. A call failed, the flow noticed, the run recorded a failure, and the next run would try again.
What we assumed
We believed the response code. A 500 means the request didn’t work, so the mailbox wasn’t attached, so the job was to get it attached. Our first fixes, which post 3 covers in detail, all followed from that belief.
The first was to wait until tomorrow: skip attaching sources if the hold was less than 30 minutes old, and let the next day’s run finish it. We rejected it. The person has to be on hold, with sources, on the day they join.
The second was to retry harder. We wrapped the mailbox attach in an Until loop that waited 2 minutes after any 5xx, 429 or timeout and tried again, for up to 15 attempts or 45 minutes. A 409 counted as “already attached”, and any other 4xx stopped the loop. It didn’t help.
Both fixes were reasonable answers to the question we were asking. The problem was the question: we were asking how to make a failed call succeed, when we should have asked what state the hold was actually in.
What the hold actually contained
So we stopped reading run history and opened the hold in the Purview portal.
The mailbox source was there. The OneDrive source was missing. The content query was empty.

That one screen overturned three things we thought we knew.
First, the mailbox attach had worked. Purview saved the source and then returned an error. The 500 told us something went wrong somewhere in a long-running control-plane operation. It didn’t tell us the source wasn’t saved, and in this case it had been. We’d seen the same shape before without recognising it: post 2’s 409 “already exists” on a hold the flow had only just created is the same thing, a request reported as failed whose effect had already landed.
Second, OneDrive was missing only because of our own control flow. Add_SiteSource ran after Add_UserSource, and only if it succeeded. Purview never refused the OneDrive site. We never asked. The flow had trusted the status code, and the status code was wrong about the thing that mattered.
Third, and most serious, the create wasn’t saving contentQuery. The hold existed, it had a source, and it had no query. That’s the state the whole design can’t recover from on its own. Remove closes a hold by rewriting the date in the KQL. With no KQL, there’s nothing to rewrite. The person would have been on hold with no end date, and the run would have reported a failure for a hold that in fact existed and was preserving that mailbox with no end date.
Notice the gap between the record and the hold. The run said the attach had failed. The truth was “mailbox attached, OneDrive not attached, no query, can’t be closed”. Neither a retry nor a better error message fixes that gap. Only reading the hold fixes it.
Why a 500 can mean done
It helps to picture what one of these calls actually is. Adding a source to a hold isn’t a row insert. It’s a request to a compliance policy that has to be saved, then distributed to Exchange and the other workloads it covers. One HTTP call stands in front of several steps inside the service, and the response reports how the call ended, not how each step went.
Post 2 got to the same place from a different direction. The explanation that fit every symptom there was that Purview creates the policy object, fails on a later step, retries internally, and runs into the object it already registered. Different symptom, same shape: the first step’s effect is real, and the error describes something that happened after it.
So a 500 at the end of six minutes is honest about the request. It just isn’t a statement about the hold. The only thing that tells you what the hold contains is the hold.
The fix: read it back, then repair the difference
We added a block to the Add flow that we called Ensure-KQL. It runs after the sources are attached, because a hold that has accepted its sources has proved its policy is live, and post 3 explains why you don’t touch a hold before then.
The logic is four steps:
GET /legalHolds/{holdId} read what the hold really contains
if contentQuery is empty:
PATCH /legalHolds/{holdId} set the intended query
GET /legalHolds/{holdId} read it back again
if contentQuery is still empty:
record an explicit error, including the query text

The second GET is the part people skip, and it’s the part that does the diagnosis. If the PATCH fixes the query, we’ve learned that the create was ignoring contentQuery and the flow has repaired it. If the query is still empty after the PATCH, Purview is rejecting this particular KQL, and that needs a person to look at the query. Without the second read, those two cases look identical in the run history: a PATCH that returned success.
The row’s Query column is now always written from what the hold actually contains, not from what the flow intended to put there. The intended query still goes to OriginalQuery, so anyone reviewing the list can compare the two. When they match, the hold is what legal asked for. When they don’t, the row says so, in words, with the query attached.
Repairing the assumptions on the other path
Once we’d seen one place where the flow assumed state, we went looking for others, and found one on the path where the person’s mailbox is already on a hold. That’s the path where Add appends a new date range to an existing query, for someone rejoining the group. It had been assuming both sources were already there, because a hold that matched on the mailbox “must” have been set up properly.
It now also POSTs the OneDrive site source on that path. If that call fails, it isn’t fatal, and a 409 counts as already attached. The point isn’t that the call usually does anything. The point is that the flow no longer believes something it hasn’t checked.
That change also turned the flow into its own repair tool. Holds left half-built on earlier days, with a mailbox but no OneDrive and no query, now get fixed on the next run: the mailbox source makes them match, the Ensure-KQL block sets the query, and the site POST adds OneDrive. That’s why the instruction that shipped with the new version was simple: run the flow once after importing it.
Ensure-KQL also outlived the bug that prompted it. When post 2’s root cause led us to create holds with no query at all and add it afterwards, the step that adds the query and verifies it was already written.
The rule to take away
The technical rule is short. For long-running control-plane operations, the response code tells you what happened to your HTTP request, not what happened to the resource. Read the resource back and fix the difference.
In practice that means three things. Don’t let a status code decide whether the next step runs when the next step is harmless to repeat. Follow every write that matters with a read of the thing you changed. And write your records from the read, never from the request.
The governance lesson is the same rule seen from the audit side. A control’s record has to describe the control as it is, not as you meant it to be. An audit trail made of intentions looks fine until someone compares it with the system, and then it’s worse than no record at all, because people trusted it. Auditability isn’t logging what you asked for. It’s reading back what the control actually contains and repairing the difference.

This is the part of the work IT has to own. As the business shapes more of its own AI tools, the compliance controls underneath them carry more weight, and they only hold up in an audit if someone has checked that the record matches the control. Legal set the rule here. The flow’s job was to make it provable, and a failure report on a hold that was quietly preserving a mailbox with no end date wasn’t proof of anything.
Next: $top=999 is a request, not a promise.
The engagement behind this series was for a large enterprise client. Names, domains, hold prefixes and identifiers have been replaced throughout. Error messages, endpoints and platform behaviour are reported exactly as observed.
CB5 Solutions helps organizations understand the layers of AI, get the most from what they buy, and build an AI program that pays back inside their IT standards. We start every engagement with one business problem and a governance baseline. Let’s pick yours.
