Trusting Fabric Data Agent output

In two previous posts I went down the path of getting a semantic model ready for AI: descriptions on every measure, an instructions file, the schema tidied up enough that a Fabric data agent has something real to read. That work has a satisfying endpoint. The model looks ready.

Ready is not the same as right.

A data agent will answer almost any question you put to it. It answers the ones it understands and it answers the ones it does not understand, in the same calm tone and with the same neatly formatted number. The demo question works in the meeting, everyone nods, and the agent goes into circulation. Three weeks later someone asks it for “sales outside the US last year” and it returns a confident figure that quietly excludes returns. Nobody checks, because checking is the exact thing the agent was supposed to remove.

I have done enough BI to distrust a number I have not reconciled. So before I let an agent answer questions on behalf of other people, I want to do to it what I would do to any new measure: feed it inputs where I already know the output, and count how many it gets right.

The instinct is borrowed from unit testing

Earlier this year I wrote about unit testing DAX with Semantic Link: a known input, a known expected value, an assertion that passes or fails. The value of that pattern is not the assertion itself. It is the discipline of writing down what you expect before you run anything, so the tool cannot talk you into accepting whatever it happens to produce.

A data agent is harder to test than a measure, because the input is a sentence and the output is a sentence. But the shape is identical. A question, an expected answer, a verdict. If I can compute the expected answer myself, I can score the agent against it.

Computing the ground truth, not guessing it

The part that matters, and the part it is tempting to skip, is that the expected answers have to be correct independently of the agent. If I let the agent propose its own expected answers, I am grading its homework with its own answer key.

So the expected column comes from somewhere I trust. For each question I write the DAX myself and evaluate it against the model with Semantic Link, the same way I built the assertions in the unit testing post. The result of that DAX becomes the expected answer. It is the same instinct as the lakehouse validation post, where I ran DAX against the source to confirm the model agreed with it. Here the DAX is the source of truth and the agent is the thing under test.

A handful of questions the business actually asks is enough to start. Total sales last year. Sales outside the US. The top product in a category. The questions people ask in Teams and then argue about.

The evaluation set

The Fabric data agent SDK has an evaluation module that does exactly this, which saved me from building a harness by hand. It is in preview, and it needs an F2 or higher capacity (or P1 or higher), so it is not something you run on a trial tenant. You install it in a notebook:

%pip install -U fabric-data-agent-sdk

The ground truth is a dataframe with two columns, question and expected_answer:

import pandas as pd

df = pd.DataFrame(
    columns=["question", "expected_answer"],
    data=[
        ["Total sales for 2013", "19,968,887.95"],
        ["Top product by sales for Canada in 2013", "Mountain-200 Black, 42"],
        ["Which category had the highest sales for Canada in 2013", "Bikes (Total Sales: 938,654.76)"],
    ],
)

I keep this in a CSV in the lakehouse so it lives in source control next to the model, rather than in a notebook cell that gets overwritten the next time someone opens it.

Running it and reading the score

from fabric.dataagent.evaluation import evaluate_data_agent

evaluation_id = evaluate_data_agent(
    df,
    "RetailSalesAgent",
    table_name="sales_eval",
    data_agent_stage="production",
)

The function asks the agent each question, compares the answer to the expected value, and writes the verdicts to a Delta table. Two tables, actually: the summary, and a sales_eval_steps table with the reasoning the agent used to get there. The steps table is where the interesting failures live, because it shows you the DAX the agent generated, not just the number it landed on.

The summary is the figure I look at first:

from fabric.dataagent.evaluation import get_evaluation_summary

get_evaluation_summary("sales_eval")

It comes back with the total number of questions and an accuracy figure, split into three buckets: true, false, and unclear. The third bucket is the honest one. It means the judge could not decide whether the agent was right, which is a result in itself and not something to round away.

Then the row level, filtered down to the ones that did not pass:

from fabric.dataagent.evaluation import get_evaluation_details

get_evaluation_details(evaluation_id, "sales_eval", get_all_rows=False, verbose=True)

That returns the question, the expected answer, the actual answer and the verdict per row, plus a link to the full thread so I can read what the agent actually did to get there.

What the failures looked like

I ran twenty questions against the retail model from the documentation posts. Most passed. The ones that did not were more useful than the ones that did, and they fell into a few recognisable shapes.

  • The ambiguous column, picked wrong. Two columns could plausibly answer a question about “region”, and the agent chose the one carried over from the source system rather than the reporting one. The number was real. It was the wrong number.
  • Wrong grain. A question about monthly sales came back aggregated to the year. Correct total, wrong shape, presented as if it were the answer asked for.
  • Wrong filter context. “Sales outside the US” came back including the US, because the agent built the filter as a positive selection it did not fully invert. This is the one that worried me most, because the figure looks reasonable and only the reconciliation catches it.
  • Plausible but false. A measure that wraps four others got interpreted from its name, and the agent confidently returned a figure that no DAX in the model actually produces.

None of these are exotic. They are the same mistakes a new analyst makes in their first week, which is the right way to think about an agent: capable, fast, and in need of supervision until it has earned otherwise.

The part I have not solved

The judge in the middle is itself a language model. By default the SDK uses a built-in critic prompt to decide whether the actual answer matches the expected one, and you can replace it with your own through the critic_prompt parameter and the {query}, {expected_answer} and {actual_answer} placeholders. That is genuinely useful when the agent says 19,968,887.95 and my expected value reads 19.97M. They are the same answer in different clothes, and a strict string comparison would fail it for no good reason.

But it cuts both ways. A lenient critic will pass answers that are close enough in wording and wrong in substance. A strict one will fail answers that are right. And because the critic is itself non-deterministic, the same evaluation can land in slightly different places on different runs. The “unclear” bucket is where that uncertainty collects, and there is no getting around reading those rows yourself.

So I do not have a clean, fully automated grade I would trust without looking. What I have is a repeatable harness that turns a vague feeling of “the agent seems fine” into a number, a list of the specific questions it got wrong, and the agent’s own reasoning for each one. That is enough to stop a bad answer reaching a stakeholder, and enough to re-run after every model change so that a description edit or a new measure does not silently break something that used to work.

Ready was never the finish line. The evaluation set is.

Power BI RLS and Fabric Data Agent considerations

In the last post I walked through auditing a semantic model before connecting it to an AI tool like Fabric Data Agent. Descriptions, naming, explicit measures, star schema: the things that decide whether a Fabric data agent generates an accurate query or a confident wrong one. I left one thing out on purpose, because it deserved its own post and because I got it wrong the first time I thought about it.

Security.

Here is the setup. I inherited a model. It had row-level security on it. Sales people saw their own region, the finance role saw everything, and the reports had been running like that for two years without a complaint. So when the request came in to connect a data agent to the workspace, I ticked the security box in my head and moved on. RLS was handled.

It was handled for people browsing reports. That is not the same thing as handled for an agent answering questions over the model, and the gap between those two sentences is where the trouble lives.


What I got wrong first

My first assumption was the dramatic one: the agent runs as some service identity, ignores the report layer, and reads everything. That is wrong, and the reason it is wrong is the useful part.

A Fabric data agent honors the security on the underlying data. Microsoft is explicit: the agent honors all user permissions to the data, including row-level security and column-level security, and for a Power BI semantic model the querying user only needs Read permission on the model, not workspace access, as documented in Fabric data agent sharing and permission management. The data agent concepts page repeats it in the limitations section: access through a data agent is governed by Read permission, and RLS and CLS still apply. So the agent is not a backdoor. It queries as the person asking the question, and that person’s RLS role filters the rows.

Good. So where is the problem? The problem is everything that decides what “the person’s RLS role” actually evaluates to. RLS is only as strong as the identity model around it, and that is where I had two real holes.


Hole one: RLS only binds Viewers

This is the one that catches people, and it caught me.

Row-level security only restricts users with the Viewer role in the workspace. It does not apply to Admins, Members, or Contributors. Those three roles carry edit permission on the semantic model, and a user who can edit the model can read all of it. Microsoft says it plainly in Row-level security (RLS) with Power BI: if you want RLS to apply to someone in a workspace, the only role you can give them is Viewer. Even a Viewer with Build permission still gets filtered. A Contributor does not.

Now connect that to the agent. The agent filters by the asking user’s identity. If the people allowed to ask the agent questions sit in the workspace as Contributors, because that is how the workspace was set up back when it was three developers and no governance, then RLS evaluates to “see everything” for every one of them. The agent is behaving correctly. It is honoring RLS. RLS just happens to be off for those identities.

The same rule applies to object-level security. OLS only binds Viewers too, as the object-level security documentation states in its limitations. The Admin, Member, and Contributor roles see the secured tables and columns regardless.

So the report side and the model side diverge exactly here. A Contributor browsing a report might still look safe, because the report only contains the visuals someone built. Point that same Contributor’s identity at the model through an agent that can write arbitrary DAX, and there is no report layer left to hide behind.


Hole two: service principals sit outside RLS entirely

The second hole is worse, because it fails silently in both directions.

Service principals cannot be added to an RLS role. The consequence, in Microsoft’s own words, is that RLS is not applied for apps using a service principal as the final effective identity. That is point three in the RLS considerations and limitations. The XMLA endpoint documentation says the same thing from the other side: service principals do not work with RLS and OLS and cannot be added as model role members.

This matters the moment any part of the chain runs as a service principal rather than a signed-in user. Automation, an embedded app, a fixed-identity Direct Lake model, a pipeline that calls the model. If the effective identity is a service principal, static RLS is simply not applied, and dynamic RLS is worse than not applied: USERPRINCIPALNAME() and USERNAME() return the application ID or an empty string for a service principal, so a filter written as [Region] = USERPRINCIPALNAME() matches nothing, or matches the wrong thing, depending on how the model was built. You either leak everything or you hand back silently incorrect slices, and neither failure announces itself.

So before connecting anything, I now need to know exactly what identity the thing on the other end runs as. “It uses RLS” is not an answer. “It queries as the signed-in user, who is a Viewer, whose role filters to their region” is an answer.


Hiding a column is not securing it

This one is subtle and I want to be careful with it.

When you set a column to hidden in the model, you remove it from the field list. You have not secured it. A hidden column is still queryable. Microsoft makes the equivalent point about perspectives, which are also just a curated view: a user can still query a table or column even when it is not visible to them, so a perspective is a convenience, not a security feature. That note lives in the report consumer security planning guidance. The hidden flag is the same kind of convenience. The agent generating DAX is not limited to your field list. It reads the model metadata, and hidden objects are still part of the model.

The actual controls are object-level security and column-level security. OLS removes the table or column for a role so completely that, to that role, it does not exist, and unlike RLS it also hides the object name and metadata, so the field cannot even be discovered. Column-level security does the same at the column grain by setting the metadata permission to none, as shown in the Analysis Services object-level security reference. You cannot author OLS in Power BI Desktop today. You set it in Tabular Editor or another external tool by setting the table or column permission to None for the role. And, to close the loop, OLS binds Viewers only, same as RLS.

So if there is a salary column, or a cost column, or anything that should not reach the agent, hiding it does nothing. Securing it with OLS or CLS does, for Viewer identities, and for no one else.


Report-level safe is not model-level safe

If there is one sentence to take from this, it is that one. Reports hide visuals. The model still answers DAX. An agent talks to the model, not to the report, so every assumption that lived in the report layer needs to be re-checked at the model layer before the agent goes live.


The check I run now

Before I connect a semantic model to a data agent, I work through this. It is short on purpose.

  1. List every RLS role and write down, in one line each, exactly what it filters. If you cannot describe a role’s filter in a sentence, you do not yet understand what the agent will return for its members.
  2. List the members of each role, and then list everyone with Admin, Member, or Contributor on the workspace. That second list is the bypass list. For those identities RLS and OLS do not apply at all. Decide whether every name on it should genuinely see all rows.
  3. Identify the agent’s effective identity. If it is a service principal or a fixed identity, RLS is not being applied, and you cannot fix that by adding the principal to a role, because you cannot add it to a role. Reach for a different pattern, such as a model whose fixed identity is itself scoped to safe data, or per-user identity passthrough.
  4. Check sensitive columns for OLS or CLS, not for the hidden flag. The hidden flag is decoration. Confirm the salary-shaped columns are actually secured, and remember that the security only binds Viewers.
  5. Test as the agent’s identity, not as yourself. The Test as role feature evaluates dynamic RLS using your own UPN, so it cannot show you what a service principal or a guest would see. Where it matters, sign in as the real identity, or run the same DAX the agent would run, as that identity, and look at the rows that come back.
  6. Default everyone who only needs to ask questions to Read on the model, nothing more. The agent needs Read, not Build, not a workspace role. Least privilege here is not a nicety. It is the only configuration where RLS actually does its job.

Where I am still unsure

I have not fully mapped how a Fabric data agent resolves identity across every sharing mode it offers. The sharing documentation is clear that the agent honors RLS and CLS and that consumers need Read on the model, and that lines up with the per-user story. What I want to test next is the boundary cases: an agent shared with a consumer who has no workspace role at all, a model on a fixed identity, and an agent invoked from automation rather than a chat window. I expect the identity that reaches the model to differ across those, and the difference is exactly what decides whether RLS holds. When I have run it, that is the next post.

Until then, the rule I am keeping is the unglamorous one. Before the agent, list the roles, list who bypasses them, find out what identity actually hits the model, and test as that identity. Not as me.


Sources