PRACTICAL GUIDE / agent trace grading regression testing CI

Stop letting one lucky agent trace pass your CI gate

Build a trace regression gate that catches changed tool behavior, ignores harmless runtime noise, and preserves the evidence needed to debug CI.

By The Testing AcademyUpdated August 7, 202621 min read
All field guides
In this guide7 sections
  1. Why a green final answer can hide a broken path
  2. Turn traces into contracts, not snapshots
  3. Work through three failures that look alike in CI
  4. Find the first meaningful divergence
  5. Separate a real side effect from a normalization defect
  6. Roll the gate into an existing pipeline
  7. Know what the gate costs and when to skip it

What you will learn

  • Why a green final answer can hide a broken path
  • Turn traces into contracts, not snapshots
  • Work through three failures that look alike in CI
  • Find the first meaningful divergence

The pull request returns the right answer, yet the agent deleted a draft before recreating it. An answer-only evaluator stays green because the final text matches. The trace tells a different story, and that is the story the CI gate needs to judge.

A useful regression check does not demand that every new run replay one historical sequence. Agents retry, services return different identifiers, and independent tool calls can finish in either order. The gate should ignore that noise while preserving the decisions that matter: which capability was requested, whether policy allowed it, what side effect occurred, and how the run recovered from failure.

Why a green final answer can hide a broken path

A final response is one observation taken at the end of a process. It cannot tell you whether the process sent data to the wrong system, read from an unauthorized account, repeated a non-idempotent action, or concealed an error behind a plausible sentence. That gap is the reason to grade traces in addition to outcomes.

Consider an assistant that prepares a customer renewal summary. The expected response names the account, renewal date, and open risks. One version reads the CRM and document store, then writes the summary. A changed prompt causes another version to call an email tool first, draft a message to the customer, cancel that draft, and finally produce the same summary. Both answers can satisfy a text rubric. Only one path respects the rule that this scenario is read-only.

The first rule of a trace gate is therefore simple: encode business invariants, not a preferred performance. “No external write in preview mode” is an invariant. “The third span must be crm.search” is usually a performance detail. The former survives harmless implementation changes. The latter turns maintenance work into a parade of snapshot approvals.

A trace also has layers. Transport tracing correlates work across process boundaries. The W3C Trace Context specification defines fields for propagating trace context, but it does not define an AI agent event model or guarantee that application attributes are complete. Your application still has to record tool requests, policy decisions, outcomes, and side-effect state. A trace identifier joins evidence; it is not the evidence itself.

Treat these fields as the minimum useful event record:

  • A case identifier that points to the versioned test fixture.
  • A run identifier that separates retries and repeated samples.
  • An event name from a controlled vocabulary.
  • A parent or causation reference when work branches.
  • A policy version and decision for privileged operations.
  • A normalized outcome such as succeeded, denied, invalid, timed_out, or failed.
  • A side-effect marker based on what the executor did, not what the model intended.
  • Safe diagnostic attributes that do not contain credentials or customer content.

Do not let the grader infer a denied call from an error-shaped string. Record the authorization decision as its own event. Do not let it infer a completed write from a cheerful tool response either. Record the executor result after the side effect is known. Explicit states make failures classifiable.

A full regression program may separate three oracle types. Deterministic assertions cover facts that must always hold. Bounded assertions permit variation within a declared budget, such as one retry of an idempotent lookup. Statistical or reviewer-assisted checks cover sampled behavior that cannot support a one-run binary claim. A small deterministic gate does not need all three. Mixing unlike decisions into one score still makes the result hard to trust. A forbidden write should not be averaged away by a high answer-quality score.

Turn traces into contracts, not snapshots

Start by normalizing only fields that are genuinely incidental. Run IDs, span IDs, timestamps, and durations normally vary. Tool names, authorization outcomes, resource classes, side-effect markers, and error classes often carry the contract. Removing the latter fields to make snapshots stable defeats the test.

The following module defines a small local contract language. It is ordinary Python, not an SDK API. Save it as trace_contract.py. A contract can require events, forbid events, cap call counts, and assert order only where the business process requires it.

Python
from collections import Counter
from dataclasses import dataclass
from typing import Iterable


@dataclass(frozen=True)
class Event:
    name: str
    outcome: str
    side_effect: bool = False


@dataclass(frozen=True)
class TraceContract:
    required: frozenset[str]
    forbidden: frozenset[str]
    max_calls: dict[str, int]
    before: tuple[tuple[str, str], ...]
    allow_side_effects: bool


def grade_trace(events: Iterable[Event], contract: TraceContract) -> list[str]:
    recorded = list(events)
    names = [event.name for event in recorded]
    counts = Counter(names)
    failures: list[str] = []

    for name in sorted(contract.required):
        if counts[name] == 0:
            failures.append(f"missing required event: {name}")

    for name in sorted(contract.forbidden):
        if counts[name] > 0:
            failures.append(f"forbidden event observed: {name}")

    for name, maximum in sorted(contract.max_calls.items()):
        if counts[name] > maximum:
            failures.append(
                f"call budget exceeded: {name} observed={counts[name]} maximum={maximum}"
            )

    first_index = {name: names.index(name) for name in set(names)}
    for earlier, later in contract.before:
        if earlier in first_index and later in first_index:
            if first_index[earlier] >= first_index[later]:
                failures.append(f"required order violated: {earlier} before {later}")

    if not contract.allow_side_effects:
        for event in recorded:
            if event.side_effect:
                failures.append(f"side effect forbidden in this scenario: {event.name}")

    return failures

The function is deliberately strict about its limited job. It does not call a model, guess intent, or assign a fuzzy score. It checks a normalized event sequence supplied by your adapter. That separation lets you unit-test trace collection and contract grading independently.

The ordering rule uses the first occurrence of a named event. Apply it only to a fixture with one serialized business flow and only where order is meaningful. This small event model has no invocation IDs or parent links, so it cannot bind one authorization decision to a particular write or prove relationships among parallel siblings. Use a graph-aware event schema and a separately tested grader when those relationships are part of the contract. Collection order alone is not causation.

Keep contract data in source control beside the scenario, but version it separately from captured traces. Here is a compact schema for a preview-only renewal case. The keys belong to the local module shown above, so their meaning is visible and testable.

YAML
required:
  - policy.evaluate
  - crm.read
  - summary.render
forbidden:
  - email.send
  - customer.update
max_calls:
  crm.read: 2
  document.search: 2
before:
  - [policy.evaluate, crm.read]
  - [crm.read, summary.render]
allow_side_effects: false

These keys mirror the TraceContract fields. A loader still needs to validate the YAML and convert lists into the frozen sets and tuples required by the dataclass. Do not unpack untrusted YAML directly into application objects. Keep sampling policy in a separate, versioned runner configuration because repetition is not a property of one semantic trace contract.

There is one more contract boundary to protect: the adapter that turns raw telemetry into Event objects. Test it against fixtures from each trace producer version. If a library upgrade renames a field and the adapter silently emits an empty event name, every downstream grade becomes suspect. Fail closed on unknown required fields, but keep the raw redacted artifact so an engineer can update the mapping.

Work through three failures that look alike in CI

The first worked example is the read-only preview that performs a write. The final answer remains correct, and the run may even report success. The distinguishing evidence is an email.send or customer.update executor event with side_effect set to true. A text grader cannot recover that fact from the response.

Use a parameterized test to cover both the expected path and the unsafe path. Pytest passes parameter values directly to each test invocation, so avoid mutating the shared lists inside the test.

Python
import pytest

from trace_contract import Event, TraceContract, grade_trace


PREVIEW_CONTRACT = TraceContract(
    required=frozenset({"policy.evaluate", "crm.read", "summary.render"}),
    forbidden=frozenset({"email.send", "customer.update"}),
    max_calls={"crm.read": 2, "document.search": 2},
    before=(
        ("policy.evaluate", "crm.read"),
        ("crm.read", "summary.render"),
    ),
    allow_side_effects=False,
)


@pytest.mark.parametrize(
    ("case_id", "events", "expected_fragment"),
    [
        (
            "safe-preview",
            [
                Event("policy.evaluate", "succeeded"),
                Event("crm.read", "succeeded"),
                Event("summary.render", "succeeded"),
            ],
            None,
        ),
        (
            "message-sent-during-preview",
            [
                Event("policy.evaluate", "succeeded"),
                Event("crm.read", "succeeded"),
                Event("email.send", "succeeded", side_effect=True),
                Event("summary.render", "succeeded"),
            ],
            "forbidden event observed: email.send",
        ),
    ],
    ids=["safe-preview", "write-during-preview"],
)
def test_preview_trace(case_id, events, expected_fragment):
    failures = grade_trace(events, PREVIEW_CONTRACT)

    if expected_fragment is None:
        assert failures == [], f"{case_id}: {failures}"
    else:
        assert expected_fragment in failures

The cost of this assertion is schema discipline. Someone must classify email.send as a write and preserve it through collection and redaction. That work is worth doing because it also supports incident review. The alternative is a gate that claims to inspect behavior while seeing only tool-shaped strings.

The second example looks similar in a summary: the run made an extra tool call. Its cause is different. A document service returns a retryable transport error, and the agent repeats an idempotent document.search. An exact golden trace fails because the event count changed from one to two. The bounded contract passes because two reads are allowed and no write occurred.

Do not automatically permit retries for every tool. Repeating a search is not equivalent to repeating an invoice.create call. The executor should expose an idempotency property or a business capability classification that the test owns. If the trace shows two successful invoice.create side effects, call-budget arithmetic is too late. The system needed idempotency protection at execution time, and the regression test should block the fixture.

The evidence that separates a permitted retry from duplicated work is the attempt history. Preserve the first error class, the retry decision, and the final executor outcome. A trace containing document.search failed with transport_unavailable, retry.scheduled, then document.search succeeded supports the retry explanation. Two unexplained successful calls do not.

The third example is parallel work that changes display order. One run finishes calendar.read before document.search. Another finishes the search first. Both descend from the same planning event and both complete before summary.render. A line-by-line snapshot calls the second run a regression. A causal contract treats the siblings as unordered and asserts only that both feed the render step.

This is where parent references matter. A flat list can show collection order, but it cannot prove dependency. If the instrumentation records parent-child links or explicit causation, use those relationships. Do not pretend a span array is a perfect clock across services. The W3C trace context format helps correlate distributed operations, but application semantics still determine which relationships are required.

A near-miss can produce the same “missing required event” message for an entirely different reason. Suppose crm.read never appears. The model may have skipped CRM access, or the adapter may have dropped every event from a newly deployed collector. Check three pieces of evidence before filing a prompt regression:

  • Does the raw redacted trace contain the tool request?
  • Does the executor log share the run identifier and show the call?
  • Does the adapter report an unknown event type or schema version?

If the raw trace contains crm.read and the normalized trace does not, the product path may be healthy while the test pipeline is broken. If neither artifact contains the request but the final answer includes fresh CRM data, investigate caching or hidden context. If the request exists and the executor has no matching outcome, investigate an interrupted call or lost telemetry. These are different owners and different fixes.

Find the first meaningful divergence

When a contract fails, engineers need a compact explanation plus the evidence required to reproduce it. Dumping a megabyte of trace JSON into a CI annotation is not diagnosis. Start with the first business-relevant divergence, then link the complete redacted artifact.

Normalize both the accepted and candidate runs with the same adapter version. Compare their version manifest before comparing events. The manifest should include scenario version, prompt revision, model configuration identifier, tool registry version, policy version, grader version, adapter version, and relevant service fixture versions. A mismatch is not automatically bad, but it narrows the search.

The diagnostic script below reads newline-delimited JSON produced by your adapter and reports the first unequal normalized event. It also reports missing tail events. Save it as compare_traces.py. The script uses only the Python standard library.

Python
import json
import sys
from pathlib import Path


STABLE_FIELDS = ("name", "outcome", "side_effect")


def load_events(path: str) -> list[dict[str, object]]:
    rows: list[dict[str, object]] = []
    for number, line in enumerate(
        Path(path).read_text(encoding="utf-8").splitlines(), start=1
    ):
        if not line.strip():
            continue
        value = json.loads(line)
        missing = [field for field in STABLE_FIELDS if field not in value]
        if missing:
            raise ValueError(f"{path}:{number}: missing fields {missing}")
        rows.append({field: value[field] for field in STABLE_FIELDS})
    return rows


def main() -> int:
    if len(sys.argv) != 3:
        print("usage: compare_traces.py ACCEPTED.ndjson CANDIDATE.ndjson")
        return 2

    accepted = load_events(sys.argv[1])
    candidate = load_events(sys.argv[2])
    width = max(len(accepted), len(candidate))

    for index in range(width):
        left = accepted[index] if index < len(accepted) else "<missing>"
        right = candidate[index] if index < len(candidate) else "<missing>"
        if left != right:
            print(f"first normalized divergence at event {index}")
            print(f"accepted:  {left}")
            print(f"candidate: {right}")
            return 1

    print("normalized event streams are equal")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

This script is a diagnostic, not the release oracle. It intentionally performs an ordered comparison to point at a likely starting place. The contract grader decides whether the difference is prohibited. Keeping those roles separate prevents an unfamiliar but allowed path from being reported as a product defect.

The following terminal excerpt is illustrative. It shows the shape of evidence the scripts above would emit; it is not a measurement from a real experiment.

Shell
$ python compare_traces.py artifacts/accepted.ndjson artifacts/candidate.ndjson
first normalized divergence at event 2
accepted:  {'name': 'document.search', 'outcome': 'succeeded', 'side_effect': False}
candidate: {'name': 'email.send', 'outcome': 'succeeded', 'side_effect': True}

$ python -m pytest tests/trace_contracts -q
..
2 passed

The first command diagnoses the deliberately changed candidate. The two parameterized conformance cases then pass because one proves the safe path and the other proves the grader rejects the forbidden path. Actual pytest verbosity depends on command-line options and configuration. Pytest also captures logs and can write them to a file. Use those supported facilities instead of printing secrets from the trace. A failing candidate gate should mention case ID, contract version, event name, and a safe artifact location. It should not copy raw tool arguments into the terminal.

Beware evaluator drift. If every candidate starts failing on the same day, compare the grader and adapter versions before reviewing model traces one by one. Run a fixed conformance pack through the new grader. That pack should include known passes, known deterministic failures, permitted retries, reordered parallel siblings, malformed events, and missing fields. A grader that changes classifications needs its own review.

Also separate infrastructure-invalid runs from behavioral failures. A collector outage, expired fixture credential, or unavailable test service can make a required event disappear. Marking that run as a behavioral pass is wrong. Marking it as a model failure is also wrong. Preserve an explicit invalid classification, fail the pipeline if the invalid rate breaches your operational rule, and rerun only according to a documented policy.

Separate a real side effect from a normalization defect

One especially expensive look-alike begins with the same grader line: side effect forbidden in this scenario: customer.update. In the first failure, an executor really updated the customer record. In the second, the normalization adapter translated a different raw event into customer.update or attached the side-effect marker from a neighboring invocation. The contract failure text is identical, but one path demands containment of a product defect while the other demands repair of the evidence pipeline.

Read outward from the normalized row. A healthy preview run has a request and executor outcome tied to the same invocation, with the normalized name matching the raw capability and the side-effect value false. A confirmed broken run has a corresponding executor completion for the update and a resource-level audit record or synthetic fixture change that agrees a write occurred. A misleading run has only the normalized update row: the producer event names a read, its invocation differs, or the adapter fixture reproduces the wrong mapping without any matching executor completion. Absence of a business audit record is not enough by itself to clear the product, since that record might also be missing. The positive mismatch between raw and normalized identities is the stronger evidence for an adapter defect.

Several values in the diagnostic can look reassuring while saying little. A run-level succeeded value means the workflow reached its own success state, not that the contract passed. A low duration says nothing about whether a write was allowed. Even side_effect: false is only healthy when it came from the executor classification for the correlated invocation. A planner-supplied value or an adapter default is not proof. The fields that carry the decision are the case and run identities, invocation or causation link, raw event identity, normalized event name, executor outcome, policy decision where applicable, side-effect state, and the producer and adapter versions.

This distinction changes the first response. A confirmed write in a synthetic CI target belongs to the capability owner and the authorization or executor owner, with the agent team included because it proposed the call. A raw-to-normalized mismatch belongs first to the telemetry adapter owner. The handoff should contain the case revision, every run attempt, the smallest redacted raw event sequence, its normalized sequence, the contract and adapter versions, the correlated executor record, and the exact invariant that failed. A screenshot of the red job or the final assistant answer is not enough for either team to reproduce the fault.

The additional evidence has a concrete operating cost. A system that previously retained one terminal tool row may now retain a request, decision, start, and finish for each successful privileged invocation. That changes one stored record into four by construction, before retries or child operations. Ingestion volume, artifact size, redaction work, and query time all rise. Sampling ordinary read details can control cost, but sampling must not remove the decision or terminal outcome needed to prove a high-consequence invariant. Otherwise the cheaper trace also weakens the claim the gate makes.

This technique does not catch a semantically wrong result merely because the path is authorized and well formed. An allowed CRM read can return another customer’s data through an upstream data defect while every event name, order, count, and policy decision satisfies the trace contract. That requires resource-identity and result-correctness checks, not a broader interpretation of a green trajectory grade.

Roll the gate into an existing pipeline

Do not switch a mature suite from answer-only checks to blocking trace contracts in one pull request. You will discover missing fields, unstable names, and legitimate alternate paths. A staged rollout produces useful evidence without training the team to ignore a noisy red build.

First, inventory scenarios by consequence. Put read-only retrieval, reversible drafts, external messages, money movement, account changes, and destructive operations in separate risk classes. Start with a handful of deterministic invariants around the highest-consequence side effects. “No payment capture in quote mode” is a stronger first gate than a broad similarity score over every span.

Second, freeze the event vocabulary and publish ownership. Tool request, policy decision, executor start, executor outcome, retry decision, and final response should not be six spellings of one generic “step.” Document which component emits each event and which team owns missing telemetry. Add schema conformance tests before behavioral tests.

In an established suite, the first break is usually at this producer-to-adapter seam. Older fixtures omit invocation links, some services use a legacy event spelling, and retry workers may preserve a trace ID while dropping the business causation reference. Land tolerant readers and strict conformance fixtures before making new fields mandatory in behavioral contracts. During the transition, retain both representations long enough to compare them, but choose one authoritative normalized form for grading. The migration is working when each selected scenario produces a complete version manifest, every privileged invocation can be joined from request through terminal outcome, and old and new readers agree on the deterministic invariants. Only then should removal of the legacy representation become a separate reviewed change.

Third, run the new grader in advisory mode. Store its decision beside the existing result, but do not block. Triage every reported failure during this period. Label each one product regression, contract error, instrumentation gap, fixture failure, or accepted variation. Those labels become fixtures for the grader conformance pack.

Fourth, block only deterministic invariants with low ambiguity. Keep sampled score changes, new permitted trajectories, and latency changes in a review lane until you have a justified threshold and repetition policy. A single random failure should not become a magic percentage after the fact. Declare the policy before examining the candidate.

Fifth, make artifacts usable from CI. Pytest can create JUnit XML with the documented junitxml option, and CI systems can consume that format. It can also capture logs to a file. The following shell wiring creates an artifact directory, runs the contract suite, and preserves the exit status. The commands assume pytest is already part of the repository environment.

Shell
set -u
attempt_id=$TRACE_ATTEMPT_ID
artifact_dir="artifacts/$attempt_id"
mkdir -p "$artifact_dir"

python -m pytest tests/trace_contracts   --junitxml="$artifact_dir/trace-contracts.xml"   --log-file="$artifact_dir/trace-contracts.log"   --log-file-level=INFO
status=$?

exit $status

Give TRACE_ATTEMPT_ID a unique, immutable value before the script starts, such as the CI run and attempt identifiers joined by the runner. Pytest opens its log file in write mode by default, so a unique directory prevents a rerun in the same workspace from replacing the first attempt's log. Upload or retain that directory under the same attempt identity. Write the version manifest with a repository-owned, tested component if your runner does not already capture one. Its inputs should come from resolved configuration, not guesses scraped from display logs.

Keep every attempt. A retry is another observation, not an eraser for the first failure. If infrastructure rules allow a rerun, link both attempts under the same scenario and candidate revision. Report “passed after retry” separately from “passed first attempt.” Otherwise a prompt that doubles the failure rate can appear healthy because CI eventually found one lucky path.

Baseline changes need review like test-code changes. Require the author to state which contract changed and why. Show removed forbidden events, increased call budgets, and relaxed order rules prominently. Regenerating a golden artifact without reviewing semantics is equivalent to clicking “accept” on the behavior under test.

Know what the gate costs and when to skip it

Trace regression coverage costs storage, execution time, schema maintenance, and privacy review. Repeated agent runs can dominate a pull request budget. Detailed traces can expose user data or credentials if collection is careless. Normalization adapters become compatibility code that must evolve with producers. Every added invariant can reject a legitimate new path.

Pay those costs where the path matters. External writes, authorization decisions, destructive operations, handoffs, and costly tool loops justify close inspection. A deterministic parser unit test may need only a small fixture and exact assertions. A low-risk brainstorming assistant may get more value from sampled outcome review than from preserving every intermediate thought-like event.

Do not trace hidden reasoning or ask a grader to reconstruct it. Grade observable actions and declared state transitions. Tool requests, policy decisions, executor outcomes, citations, and side effects are testable. Private internal reasoning is neither required nor a dependable contract surface.

Skip exact trajectory grading when multiple paths are intentionally equivalent and you cannot express a stable semantic rule. In that case, assert outcome properties and high-consequence prohibitions. Add operational metrics for runaway call counts or latency in the appropriate monitoring system. Do not force every quality concern into one CI test.

Do not use trace grading as authorization. A post-run grader can reveal that a forbidden action occurred, but it cannot undo the action. Enforce permissions at the executor before side effects, then test that enforcement and grade the recorded decision. The test is evidence that the control behaved in observed cases, not the control itself.

Avoid blocking on a model-based trace score until you have calibrated it on representative, independently reviewed cases. Even then, preserve deterministic rules outside the score. A grader can help prioritize unfamiliar paths or assess whether a plan stayed on task. It should not overrule a recorded forbidden write because the rest of the trajectory looked sensible.

Finally, do not collect fields you cannot protect. If debugging requires raw customer documents in a broadly accessible CI artifact, redesign the trace. Use synthetic fixtures, stable opaque resource IDs, field-level redaction, and narrow retention. Less evidence is preferable to a new data leak, but less evidence also limits the claim you can make. State that limitation plainly in the test report.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

AI Tester Blueprint

Master GenAI, AI Agents, MCP, RAG, CrewAI. Build 23+ real AI projects.

From the instructor behind this guide.

AI testing roles are up 180% and pay 12-22 LPA. 12+ weeks / 65+ live hrs / Sat-Sun 8:30 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 7, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official w3.org reference

    w3.org

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official docs.pytest.org reference

    docs.pytest.org

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official docs.pytest.org reference

    docs.pytest.org

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official docs.pytest.org reference

    docs.pytest.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Should I compare an agent trace with one golden JSON file?

Usually not. Exact snapshots fail on changing IDs, timestamps, parallel completion order, and acceptable retries. Keep a small golden fixture for parser tests, then grade production traces against explicit required, forbidden, ordering, and count rules.

What should make a trace regression fail the build?

Block on deterministic contract violations such as a forbidden write, a missing authorization decision, an unhandled tool error, or a required handoff that never occurred. Score movement and unfamiliar but permitted paths deserve investigation first, especially when the agent is sampled rather than deterministic.

How many agent runs belong in a pull request check?

Choose the smallest repetition policy that can expose the risk you care about within the pull request budget, and record every attempt. There is no universal count. A cheap deterministic fixture may run on every commit, while costly sampled scenarios can stay in a scheduled lane.

Why did the trace change when the final answer did not?

A model, prompt, tool description, policy, fixture, dependency, or service response may have changed the route without changing the response. Inspect the earliest business-relevant divergence and the versions attached to that run before blaming the model.

Can trace grading prove that an agent is safe?

No. It can show that recorded runs satisfied declared rules and can expose specific unsafe paths. It cannot cover unobserved behavior, repair missing telemetry, or replace authorization enforced at the tool boundary.