Testing a Data API for AI Callers: Contracts, Evaluations and Drift
Part 3: how to prove, before every release and in production, that a governed data API gives people and AI agents the right answer, for the right person, in a form an agent can use
Core definition. A data API is ready for AI callers when five things are proven automatically before every release and watched in production: the contract holds, the answer is right on every path, access is right for every user, the agent surface is usable, and nothing is drifting.
Part 1, “Data Is Becoming an API, and AI Is the New Caller”, argued that AI agents are becoming a major caller of enterprise data. Part 2, “Building an AI-Ready Data Services API on Azure, End to End”, built one: a governed net-revenue metric served over REST and as an MCP tool, with Microsoft Entra ID, budgets and tracing. This part answers the question an auditor, a risk officer or an on-call engineer will ask next: how do you know it keeps working?
Why this needs its own discipline. A person who gets a wrong number usually notices. An agent may pass it on in every answer it gives, and nobody may check. Unit tests written from a few examples do not cover what an agent will try: odd date ranges, regions it should not see, the same failing call again and again. The checks below are designed for that caller.
What was run. All Python ran locally against Part 2’s service, with DuckDB standing in for the Azure data store and test tokens signed with a local key. The main suite of 60 tests passed (Part 2’s 28 and 32 new), and the schema-generated contract suite passed with 100 generated requests. The release gate ran locally up to the deploy command. The Application Insights queries and the deploy command were checked against Microsoft’s documentation, not run.
Key takeaways
- Let the schema write tests. On its first run, the schema-generated suite found two places where the published contract and the service disagreed.
- Pin the answer, then prove every path agrees. The REST endpoint and the MCP tool must return the same rows, definition, source and freshness.
- Test access as a matrix. Every user against every region, for the app and for an agent acting for that user.
- Lint what the agent reads, and evaluate agents from their traces. Descriptions, schemas and error text are an interface; outcomes in the audit trail show which agents are struggling.
- Make drift a release blocker. A breaking contract change or late data stops the deploy.
The assurance map
1. Let the schema write the tests
Core: the published contract is a test generator. Use it, and treat every disagreement as a finding.
- What: property-based contract testing. Schemathesis reads the OpenAPI document, generates many valid and invalid requests, calls the app in process and checks each response against the schema.
- Why needed: an agent will send requests no one thought to write down. Hand-written tests confirm the cases the author imagined; generated tests explore the rest of what the contract allows.
- How: load the schema from the ASGI app and teach the generator the one rule OpenAPI cannot express: a range of one to 24 months across two separate parameters.
"""Contract: let the published schema generate the requests, and check every response against it."""
import re
import pytest
import schemathesis
import app as app_module
from conftest import RAJ, make_token
from models import MONTH
LAST = 2099 * 12 + 11 # December 2099, the last month the published pattern allows
@schemathesis.hook
def map_query(ctx, query):
"""Teach the generator the one rule the schema cannot express: a range of 1 to 24 months."""
months = [query.get(k) for k in ("from_month", "to_month")] if isinstance(query, dict) else []
if len(months) == 2 and all(isinstance(v, str) and re.fullmatch(MONTH, v) for v in months): # valid cases only
y, m = map(int, query["from_month"].split("-"))
span = int(query["to_month"][-2:]) % 24 # 0-23 extra months, varied by the generator
start = min(y * 12 + m - 1, LAST - span)
to = start + span
query["from_month"] = f"{start // 12}-{start % 12 + 1:02d}"
query["to_month"] = f"{to // 12}-{to % 12 + 1:02d}"
if "page_token" in query: # a page token must fall inside the range
query["page_token"] = query["from_month"]
return query
@pytest.fixture
def api_schema():
return schemathesis.openapi.from_asgi("/openapi.json", app_module.app)
schema = schemathesis.pytest.from_fixture("api_schema") # loaded when the test runs, not at import
@schema.parametrize()
def test_every_generated_request_gets_a_documented_answer(case):
case.call_and_validate(headers={"Authorization": f"Bearer {make_token(oid=RAJ)}"})- Effectiveness: the first run failed, correctly. Schemathesis generated
from_month=0000-01, which Part 2's pattern allowed, and the service refused it with422. Its report read:ST202 Schema-valid request rejected. The contract said yes and the service said no. Two fixes followed. The month pattern now allows only years 20xx. And the refusals that depend on the data rather than on the request's form now use the status codes that mean "a valid request, but not this data":409 Conflictfor a month that is not certified yet (RFC 9110 describes 409 as "a conflict with the current state of the target resource") and404 Not Foundfor a month before the data begins. Both are declared in the published responses. The next runs caught a mistake I introduced while making that change, then found the cross-parameter rule, which is why the hook above exists. With both changes, 100 generated requests pass. - When applicable: any API with a machine-readable schema. Run it in its own process: Schemathesis starts the app’s lifespan, and the MCP session manager in Part 2’s app can start only once per process.
The two contract fixes, as excerpts from models.py and service.py:
# models.py
MONTH = r"^20[0-9]{2}-(0[1-9]|1[0-2])$" # this data product starts in 2025
# service.py (messages shortened here)
raise DataApiError(409, "month_not_closed", ...) # valid request; the month is not certified yet
raise DataApiError(404, "before_first_month", ...) # valid request; no data that early2. Pin the answer, then prove every path agrees
Core: a correct number on one path is not enough; every caller must get the same answer with the same evidence.
- What: a golden test that pins certified values, and a parity test that compares the REST answer with the MCP tool’s answer.
- Why needed: the agent path and the app path can drift apart quietly, through a different default, a different rounding or a missing field. An agent that reports a different number from the dashboard destroys trust in both.
- How:
GOLDEN = {("WEST", "2026-09"): 1840210.55, ("WEST", "2026-10"): 1902774.10}
def test_golden_answers(app):
body = TestClient(app).get(URL, params=PART1, headers={"Authorization": f"Bearer {make_token()}"}).json()
assert {("WEST", r["month"]): r["net_revenue_usd"] for r in body["rows"]} == GOLDEN
def test_same_answer_on_every_path(live_server):
import httpx
with httpx.Client(base_url=live_server, headers={"Authorization": f"Bearer {make_token()}"}) as http:
rest = fetch_net_revenue(http, **PART1)
ok, mcp_answer = anyio.run(call_net_revenue, make_token(azp=AGENT_APP), PART1, f"{live_server}/agents/mcp")
assert ok
for key in ("rows", "metric_definition", "as_of", "source"):
assert rest[key] == mcp_answer[key], key- Effectiveness: both tests passed over real HTTP. The parity test compares everything except the trace ID, which differs by design.
- When applicable: whenever one metric is served through more than one path. Refresh the golden values only through the same review that certifies the data.
3. Test access as a matrix, not as examples
Core: for an agent acting on someone’s behalf, every user and every object is a test case.
- What: one parameterized test that runs every user against every region, once through the app and once through an agent acting for that user.
- Why needed: the OWASP API Security Top 10 (2023) ranks Broken Object Level Authorization first. A handful of example tests misses the combinations, and agents try combinations.
- How:
USERS = {ANA: {"WEST"}, RAJ: set(REGIONS), "oid-no-entitlements": set()}
@pytest.mark.parametrize("oid,region,client_app", list(product(USERS, REGIONS, [WEB_APP, AGENT_APP])))
def test_access_matrix(app, oid, region, client_app):
r = TestClient(app).get(URL, params={**PART1, "region": region},
headers={"Authorization": f"Bearer {make_token(oid=oid, azp=client_app)}"})
if region in USERS[oid]:
assert r.status_code == 200 and {row["month"] for row in r.json()["rows"]} == {"2026-09", "2026-10"}
else:
assert r.status_code == 403 and r.json()["code"] == "region_not_entitled"- Effectiveness: 24 cases (3 users, 4 regions, 2 calling apps), all passing. The agent never received more than its user may see.
- When applicable: any API that filters by owner, region, account or tenant. Generate the matrix from the entitlement table, so new users and regions are covered without new code.
4. Lint what the agent reads
Core: for an AI caller, the tool description, schema and error text are the interface, so check them like code.
- What: a linter for the MCP tool definition and for the error text a model receives.
- Why needed: a vague description or an unbounded input lets a model call the tool for the wrong question or with arguments it cannot validate. An error without a next step leaves the model to guess.
- How:
"""Lint the tool surface an AI agent reads: description, input bounds, output contract, annotations."""
import re
REQUIRED_EVIDENCE = {"metric_definition", "as_of", "source", "trace_id"}
def _bounded(prop: dict) -> bool:
return any(k in prop for k in ("enum", "pattern", "maximum", "maxLength", "const"))
RULES = {
"description_states_scope": lambda t: len(t.description or "") >= 80,
"description_says_what_it_does_not_do": lambda t: bool(re.search(r"\b(does not|do not)\b", t.description or "", re.I)),
"every_input_is_bounded": lambda t: all(_bounded(p) for p in t.input_schema.get("properties", {}).values()),
"inputs_are_required_or_defaulted": lambda t: all(
name in t.input_schema.get("required", []) or "default" in p
for name, p in t.input_schema.get("properties", {}).items()),
"output_carries_its_evidence": lambda t: REQUIRED_EVIDENCE <= set((t.output_schema or {}).get("required", [])),
"declares_read_only": lambda t: bool(t.annotations and t.annotations.read_only_hint),
}
def lint_tool(tool) -> list[str]:
"""Return the names of the rules this tool fails; an empty list means it passes."""
return [name for name, rule in RULES.items() if not rule(tool)]
def lint_error_text(text: str) -> list[str]:
"""An error the model will read must say what was wrong and what to do instead."""
problems = []
if len(text) < 40:
problems.append("error_too_short_to_act_on")
if not re.search(r"\b(retry|use|ask|send|call)\b", text, re.I):
problems.append("error_gives_no_next_step")
return problems- Effectiveness: Part 2’s tool passed every rule, and so did its error text for an open month. As a control, the generic message “Invalid request.” failed both error rules. The read-only declaration is a hint for clients, not a control: the MCP specification says clients “MUST consider tool annotations to be untrusted unless they come from trusted servers,” so the server still enforces access itself (section 3).
- When applicable: every tool or endpoint an agent can call. Agree the rules with the teams that write tools, and keep them few enough to remember.
5. Evaluate agents from their traces
Core: the audit trail already records how each agent uses the data; score it.
- What: an evaluator that reads the
data_api.*attributes on each request span and reports, per calling app or agent, its success rate, how often it recovers after an error, how often it repeats the same error and how often it is refused. - Why needed: a model that ignores error text, or keeps asking for data its user may not see, will not show up in unit tests. It shows up in behaviour. You can measure that behaviour without access to the model.
- How: one change to Part 2’s service records the outcome on every answer and every refusal, on both paths. For example, the REST error handler gains
audit(outcome=e.code)and a successful answer recordsoutcome="ok". The evaluator then reads the spans:
"""Evaluate how each calling app or agent uses the data API, from the audit attributes on its spans."""
from collections import defaultdict
def calls_from_spans(spans) -> list[dict]:
"""One record per answered or refused call, in time order, from spans that carry an outcome."""
calls = []
for s in spans:
a = dict(s.attributes or {})
if "data_api.outcome" in a:
calls.append({"time": s.start_time, "trace": f"{s.context.trace_id:032x}",
"app": a.get("data_api.client_app"), "user": a.get("data_api.user_oid"),
"path": a.get("data_api.path"), "outcome": a["data_api.outcome"]})
return sorted(calls, key=lambda c: c["time"])
def evaluate(calls: list[dict], window: int = 2) -> dict[str, dict]:
"""Per calling app: success rate, refusals, recovery after an error, and repeated identical errors."""
by_app = defaultdict(list)
for c in calls:
by_app[c["app"]].append(c)
report = {}
for app, seq in by_app.items():
errors = [i for i, c in enumerate(seq) if c["outcome"] != "ok"]
recovered = sum(any(n["outcome"] == "ok" for n in seq[i + 1:i + 1 + window]) for i in errors)
repeats = sum(1 for a, b in zip(seq, seq[1:]) if a["outcome"] != "ok" and a["outcome"] == b["outcome"])
outcomes: dict[str, int] = {}
for c in seq:
outcomes[c["outcome"]] = outcomes.get(c["outcome"], 0) + 1
report[app] = {
"calls": len(seq),
"success_rate": round(outcomes.get("ok", 0) / len(seq), 2),
"recovery_rate": round(recovered / len(errors), 2) if errors else None,
"repeated_errors": repeats,
"entitlement_refusals": outcomes.get("region_not_entitled", 0),
"outcomes": outcomes,
}
return report
def verdict(r: dict, min_recovery: float = 0.8) -> str:
"""A simple gate: an agent that repeats the same error, or rarely recovers, needs attention."""
if r["repeated_errors"] or (r["recovery_rate"] is not None and r["recovery_rate"] < min_recovery):
return "review"
return "pass"- Effectiveness: two scripted callers, standing in for model-driven agents, made real calls over Streamable HTTP. The “careful” caller asked for an open month, read the error and corrected itself: success rate 0.5, recovery rate 1.0, verdict
pass. The "sloppy" caller asked for two open months in a row and then for a region its user may not see: one repeated error, one entitlement refusal, verdictreview. The evaluator scores behaviour, not the model's reasoning; it tells you which agent to examine, not why it failed. - When applicable: as soon as more than one agent or app calls the API. In production, the same attributes are in Application Insights (section 7).
6. Make drift a release blocker
Core: two kinds of drift break AI callers quietly: a contract change, and data that is late or out of pattern.
- What: a contract diff that lists changes that can break an existing caller, and data checks for freshness and level shifts.
- Why needed: an agent built against last month’s schema will not read release notes. And a certified table that stops updating keeps answering, with a correct-looking
as_ofthat is simply old. - How: an excerpt of
drift.py:
def breaking_changes(old: dict, new: dict) -> list[str]:
"""Changes that can break an existing caller of the API (additions are allowed)."""
found = []
for path, ops in old["paths"].items():
for method, op in ops.items():
new_op = new["paths"].get(path, {}).get(method)
if new_op is None:
found.append(f"{method.upper()} {path}: operation removed")
continue
old_params = {p["name"]: p for p in op.get("parameters", [])}
for p in new_op.get("parameters", []):
was = old_params.get(p["name"])
if p.get("required") and not (was and was.get("required")):
found.append(f"{path}: parameter '{p['name']}' is now required")
if was and "enum" in was.get("schema", {}):
lost = set(was["schema"]["enum"]) - set(p.get("schema", {}).get("enum", was["schema"]["enum"]))
if lost:
found.append(f"{path}: parameter '{p['name']}' no longer accepts {sorted(lost)}")
for name in old["components"]["schemas"]:
if name not in new["components"]["schemas"]:
found.append(f"schema {name} removed")
continue
(old_p, old_req), (new_p, new_req) = _props(old, name), _props(new, name)
for prop in old_p:
if prop not in new_p:
found.append(f"{name}.{prop} removed")
elif old_p[prop].get("type") != new_p[prop].get("type"):
found.append(f"{name}.{prop} changed type")
for prop in old_req - new_req:
if prop in new_p:
found.append(f"{name}.{prop} is no longer guaranteed")
return founddef data_checks(con, today: date, sla_days: int = 3, mad_limit: float = 5.0) -> list[str]:
"""Late data (the contract's latency SLA) and month values far outside each region's recent pattern."""
found = []
y, m = today.year, today.month - (1 if today.day > sla_days else 2) # last month that should be certified
while m < 1:
y, m = y - 1, m + 12
expected = f"{y}-{m:02d}"
latest = con.execute("SELECT MAX(month) FROM finance.net_revenue_monthly").fetchone()[0]
if latest < expected:
found.append(f"stale: latest certified month is {latest}, expected {expected} within {sla_days} days")
rows = con.execute("""SELECT region, month, gross_sales_usd - returns_usd - discounts_usd
FROM finance.net_revenue_monthly ORDER BY region, month""").fetchall()
series: dict[str, list[tuple[str, float]]] = {}
for region, month, value in rows:
series.setdefault(region, []).append((month, float(value)))
for region, points in series.items():
*history, (month, value) = points[-7:]
values = [v for _, v in history]
median = statistics.median(values)
mad = statistics.median(abs(v - median) for v in values) or 1.0
if abs(value - median) / mad > mad_limit:
found.append(f"{region} {month}: {value:,.2f} is far outside the last six months (median {median:,.2f})")
return found- Effectiveness: adding an optional
currencyfield was correctly allowed. Removingsource, makinglimitrequired and droppingEASTfrom the region list were all caught, by name. On the data side, the table passed on November 5; on December 4 it was flagged as stale against the three-day latency in the data contract; and a planted tripling of one region's month was flagged as far outside its last six months. The level-shift check uses the median and the median absolute deviation, which a single earlier outlier cannot drag around as it would a mean. - When applicable: run the contract diff on every change to the API, and the data checks on a schedule and before every release. Set the deviation limit from your own history; the value here is an example.
7. In production: the same signals, as queries
Core: what the tests prove before release, telemetry should keep showing after it.
- What: two Kusto queries over the Application Insights
AppRequeststable. - Why needed: traffic in production is the only place where real agents, real users and real data meet.
- How: both paths record the outcome on a request (server) span, which the Azure Monitor exporter sends as a request with the span’s attributes as custom properties, so they appear in the
Propertiescolumn:
// Outcomes by calling app and agent over the last day; span attributes arrive in Properties
AppRequests
| where TimeGenerated > ago(1d)
| extend client_app = tostring(Properties["data_api.client_app"]), outcome = tostring(Properties["data_api.outcome"])
| where isnotempty(outcome)
| summarize calls = count() by client_app, outcome
| order by client_app asc, calls desc
// Reconstruct any answer from the trace_id it returned
AppRequests
| where OperationId == "<trace_id from the response>"
| project TimeGenerated, Name, ResultCode, DurationMs, Properties- Effectiveness: not run here; the table and column names follow Microsoft’s
AppRequestsreference. The first query feeds the same evaluation as section 5; the second turns any disputed answer into its request, user, calling app and data version. - When applicable: daily review of agent behaviour, and any time an answer is questioned.
8. One gate before every release
Core: every check above runs before a deploy, and any failure stops it.
#!/usr/bin/env bash
# Release gate for the data API: run in CI before every deploy. Any failing step stops the release.
set -euo pipefail
pytest -q tests --ignore=tests/test_schema_generated.py # answers, access, agent surface, traces, data drift
pytest -q tests/test_schema_generated.py # schema-generated requests, in their own process
python -c "import app, json; json.dump(app.app.openapi(), open('current_openapi.json', 'w'))"
python drift.py baseline/openapi.json current_openapi.json # exits 1 on any breaking contract change
# Only after every check passes: build and deploy (Part 2, section 9)
az containerapp up --name net-revenue-api --resource-group rg-net-revenue --source . \
--ingress external --target-port 8000Locally, every step before the deploy command passed, and the contract diff returned exit code 1 when fed a contract with source removed, which would have stopped the release.
What each check proves
What these checks do not prove
Core: know the edges of your evidence.
- Model reasoning. The trace evaluation scores what an agent did with the data, not whether its final answer to a person was right. That needs a separate evaluation of the agent itself.
- Real identity and gateways. Tokens were signed locally; Entra-issued tokens and API Management policies were not exercised.
- Scale. None of this measures behaviour under production load or concurrency.
- The data’s truth. The golden values prove the API returns what was certified, not that the certification was correct.
A checklist: is your data API proven for AI callers?
- Contract: does a schema-generated suite run on every change, in its own step?
- Answer: are certified values pinned, and do all paths return the same answer and evidence?
- Access: is every user tested against every object, for apps and for agents acting for them?
- Agent surface: are tool descriptions, input bounds and error text linted?
- Behaviour: is every call’s outcome in the audit trail, and is each agent’s recovery rate reviewed?
- Drift: do breaking contract changes and late or out-of-pattern data block a release?
- Production: can any answer be traced back to its request, user, calling app and data version?
The core, in one line
A data API is ready for AI callers when its contract, answers, access, agent surface and freshness are proven by code before every release and kept visible in production.
Get Reno Christy’s stories in your inbox
Join Medium for free to get updates from this writer.
I used an AI writing assistant to help draft and edit this article. I reviewed and revised the text and checked every source.
References
- R. R. Christy, “Data Is Becoming an API, and AI Is the New Caller,” Part 1, Medium, November 4, 2026. https://medium.com/@renorajan9/data-is-becoming-an-api-and-ai-is-the-new-caller-e4da9abde8d3
- R. R. Christy, “Building an AI-Ready Data Services API on Azure, End to End,” Part 2, Medium, November 5, 2026. https://medium.com/@renorajan9/building-an-ai-ready-data-services-api-on-azure-end-to-end-b86d4468b66d
- Schemathesis, “Testing Python Apps” and “How Schemathesis Integrates with Pytest” (version 4.33.1 used). https://schemathesis.readthedocs.io/en/stable/guides/python-apps/
- R. Fielding, M. Nottingham and J. Reschke (eds.), “HTTP Semantics,” RFC 9110, June 2022, sections 15.5.5, 15.5.10 and 15.5.21. https://www.rfc-editor.org/rfc/rfc9110.html
- OWASP, “OWASP Top 10 API Security Risks — 2023.” https://owasp.org/API-Security/editions/2023/en/0x11-t10/
- Model Context Protocol, Specification (July 28, 2026 revision), “Tools.” https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- Microsoft Learn, “Azure Monitor Logs reference — AppRequests.” https://learn.microsoft.com/en-us/azure/azure-monitor/reference/tables/apprequests
- Bitol, “Open Data Contract Standard (ODCS),” v3.2.0: service-level agreement (latency). https://bitol-io.github.io/open-data-contract-standard/latest/
The views expressed are the author’s own. Example values, users, regions and agents are illustrative.








