← all field notes

Field note · May 26, 2026 · explainability

Why did the agent do that?

A customer emails support: three weeks ago your bot refused to refund my October order. My card was charged twice. I want to know why you said no. Engineering opens the ticket. The application log shows the agent took an action. It does not show what the agent saw, which policy was live, or why the rule that fired fired. The answer to the customer's question is a best guess.

That is the explainability gap. It is not a property of the model. It is a logging discipline you applied — or didn't — at the boundary where the agent did something irreversible.


The three things that need to live in one record

A decision the agent made three weeks ago is reconstructable if and only if you can answer three questions from a single log line:

  1. What did the agent see? The fingerprint of the input that reached the tool call. Not the entire payload — that often contains PII you don't want in an audit log forever — but enough to identify the case.
  2. Which policy decided? The version of the policy file at the moment the decision was made. Not the current version; the historical one. A fix you shipped this week must not silently rewrite last month's history.
  3. Why? The list of reasons that resolved to a denial or an approval, in human-readable strings, derived from the policy that ran.

Three fields. If any one is missing, the reconstruction is best guess.

What the evidence event looks like

Each supervised action emits an evidence event the moment the decision is made. Below is the actual shape the supervisor writes, inlined from a denied refund:

{
  "event_id": "ev_2026-04-09T14:22:11Z_4f2a",
  "action_type": "refund",
  "input_fingerprint": "sha256:8c2e…",   // not the raw payload
  "decision": "deny",
  "risk_score": 0.82,
  "reasons": [
    "refund_velocity_24h > 3 (saw 5)",
    "customer_age_days < 30 (saw 11)",
    "amount > 500 (saw 729.00)"
  ],
  "threats": [
    { "detector_id": "refund-burst",
      "owasp_ref": "LLM06",
      "level": "warn",
      "message": "5 refunds in 24h for new account" }
  ],
  "policy_version": "refund.base@v1.4",
  "policy_ref": "packages/policies/refund.base.v1.yaml#L42-L78",
  "enforcement_mode": "enforce",
  "occurred_at": "2026-04-09T14:22:11.842Z"
}

Three weeks later you look this up by customer_id or by occurred_at. The reasons array tells you, in English, what the agent saw and what fired. The customer gets a real answer.

Replay a past decision against today's policy

The customer's next question is usually would it still deny if I tried again? The supervisor exposes the same decision endpoint in dry-run mode, so you can re-evaluate the same input against the current policy without committing to anything:

curl -X POST https://api.vibefixing.me/v1/actions/evaluate \
  -H 'authorization: Bearer …' \
  -d '{
    "action_type": "refund",
    "input": { /* the original payload, retrieved by input_fingerprint */ },
    "dry_run": true
  }'

# response
{
  "decision": "review",                  # not 'deny' anymore
  "risk_score": 0.61,
  "reasons": [
    "refund_velocity_24h > 3 (saw 5)",
    # customer_age_days check removed in policy v1.6
  ],
  "policy_version": "refund.base@v1.6"
}

Now the engineer can answer the customer with precision: the policy in effect three weeks ago denied; today's policy would route the same case to a human reviewer. The policy diff between v1.4 and v1.6 is visible in source control. The ticket closes with a one-line explanation instead of a corporate paragraph that says nothing.

The wrap

On the agent side, the discipline is one decorator at the chokepoint. The supervisor writes the evidence event whether the decision was allow, deny, or review — failures are recorded too:

from supervisor_guards import supervised

@supervised("refund")
def issue_refund(customer_id: str, amount: float, reason: str) -> Refund:
    return stripe.refunds.create(
        charge=resolve_charge(customer_id),
        amount=int(amount * 100),
        reason=reason,
    )

Every call to issue_refund now produces a record of what reached the supervisor and what the supervisor decided, before the Stripe call happens. There is no extra logging code in the function body. The agent code didn't change shape; it just became reconstructable.

What I'm not worried about

PII bloat in the log. The evidence event stores an input_fingerprint, not the raw payload. The raw input sits in the action store with whatever retention policy your data team already runs; evidence retention is configurable separately. Replay works because you can hand the original payload back to the dry-run endpoint when you need it, not because we kept a copy forever.

Your dashboards are also fine. The evidence chain is append-only and hash-linked; it sits next to the operational logs your team already grep. You don't move to a new logging stack. You add a column.


related

The explainability section of the risk hub.

Same idea, framed for someone landing on the site for the first time: explainability isn't a model property, it's a logging discipline at the boundary.