Structured Refusal & Why-Trails

7 min read

S6
Deep Dive · Agent Security

A refusal that says "I can't help with that" is a dead end; an enumerated refusal reason plus a why-trail is an accountability primitive — and the structured reason field your policy engine already returns is 90% of the work.

"I'm sorry, I can't help with that" is useless to a forensic investigator and only slightly less useless to the user who wants to know what to fix. The 2026 alternative is a structured refusal: an enumerated reason code — the kind Microsoft's Agent Governance Toolkit already returns in PolicyDecision.reason, or the OATR rejection reason codes — paired with a why-trail that records which rule fired against which input. Logs support forensics; why-trails support accountability under examination. This short essay is the minimum useful refusal-reason taxonomy, the why-trail-versus-log distinction, and how to keep a why-trail auditable without leaking the sensitive reasoning behind the refusal. It deliberately stops at the boundary: the policy mechanism lives in policy-as-code, and the signing and storage live in decision receipts.

STEP 1

Why prose refusals fail two audiences.

A prose refusal is a sentence the model writes to explain a "no." It fails two audiences at once. The user wants to know what to change — was the request disallowed, was one argument out of bounds, or is the block temporary? — and "I can't help with that" answers none of those, so they retry blindly and, worst case, learn to route around the guardrail by trial and error. The investigator reading the same refusal weeks later, during an incident review, wants something the sentence can never give: a stable identifier for which control fired, so they can count it, correlate it across sessions, and prove to an examiner it was in force. Free text is not queryable and not stable — the same block gets phrased ten ways across ten sessions.

The deeper problem is that a model-authored refusal is a generated artifact, not a record of what happened. When the decision comes from a policy engine at the tool-call boundary — the pattern in policy-as-code for agents — the model's apology is a downstream narration, and narration drifts: it may soften a hard deny into "let me suggest an alternative," or invent a reason the policy never gave. The refusal that matters for accountability is the one the enforcement layer produced. This is the same point the guardrails primer makes: a guardrail that only blocks is half-built; one that blocks and emits a legible, enumerated verdict is one you can operate.

STEP 2

The enumerated refusal-reason taxonomy.

The fix is to make the refusal reason an enumerated code — a value from a small, closed, versioned set — instead of a sentence. The 2026 tooling already ships this shape. Microsoft's Agent Governance Toolkit returns a PolicyDecision whose action field is drawn from a fixed vocabulary (allow / deny / audit / block) and whose matched_rule names the exact rule that fired; the human-readable reason rides alongside as presentation, not as the identifier. The OATR runtime-attestation proposal (its v1.2.0 revision, March 2026) went further and shipped an explicit set of enumerated rejection reason codes, so a rejected attestation says why in a machine-comparable token, not a paragraph. The primitive is the enumeration; the prose is a rendering of it.

You do not need a large taxonomy — a large one is a liability, because every code is a contract a downstream consumer depends on. The minimum useful set separates the categories a user or an investigator would act on differently:

  • Policy-denied — a rule explicitly forbade this action or these arguments; retrying unchanged will fail again. This is the matched_rule case.
  • Out-of-scope — the action falls outside this agent's granted capabilities, not a content rule. The path is a different agent or an escalation, not a rephrase.
  • Attestation / identity failed — the caller could not prove who it is (the OATR rejection case). The request may be legitimate but unverifiable right now.
  • Input rejected — the request tripped a content or safety filter on its own text, independent of the tool it targeted.
  • Unavailable / failed-closed — the decision point could not render a clean verdict and the gateway defaulted to deny. This is temporary and retryable, and it must stay distinguishable from a real policy deny, or an outage hides behind what looks like a rule.

That last distinction is the one teams most often get wrong: a user — or an on-call engineer — who sees the same opaque "no" for a permanent policy rule and a transient PDP timeout cannot tell a design decision from an incident. Keep the codes closed and versioned: adding a code is compatible, but re-meaning an existing one silently breaks every dashboard and alert built on it.

STEP 3

Why-trail vs log.

An enumerated code tells you which control fired. A why-trail tells you why it fired against this specific input — the ordered chain of evaluations that led to the verdict: which rules were considered, which fields they matched, which one was decisive. It is the difference between "denied: policy-denied" and "denied because the path matched the sensitive-paths rule at /workspace/.env, after the approved-workspace rule failed to match." The code is the label; the why-trail is the derivation.

This is not the same as a log, and the distinction is load-bearing. A log is a stream of events written to reconstruct what happened — optimized for volume and for answering "what did the system do at 14:07." A why-trail is optimized for a different question: "can you justify this one decision to someone examining it." A log answers what happened; a why-trail answers why this was refused. The framing worth remembering, from the practitioners who named the pattern: logs support forensics; why-trails support accountability under examination. You can have exhaustive logs and still be unable to defend a single refusal, because the log records the outcome without the reasoning that produced it.

Concretely, the why-trail is a small structured object attached to the decision: the enumerated reason code, the decisive rule, the input fields it matched (by name and class, per Step 4), the policy version in force, and a timestamp. It is bounded — one decision, one trail — where a log is unbounded, and that boundedness is what makes it examinable: an auditor reads the one trail a refusal produced, not your whole log. The trail is also the natural payload for the layer above. What gets signed and hash-chained into a tamper-evident record — so it cannot be quietly edited after the fact — is the subject of the group's essay on decision receipts and audit; this essay stops at producing the trail, not sealing it.

STEP 4

Auditable without leaking.

A why-trail has a tension baked in: to be useful it must record what the input was, but the input may be exactly the sensitive thing the refusal exists to protect. Log the raw value that tripped a secret-exfiltration rule and your accountability record becomes a second copy of the secret, now sitting in an audit store a broader set of people can read. Redact it entirely and the trail is unexaminable: an auditor cannot tell a correct refusal from an over-broad one. The resolution is to record the rule and the input class, not the raw sensitive content.

In practice: capture that the decisive rule was sensitive_path, that it matched the args.path field, and that the field's class was "credential-file path" — but store a redaction token or a salted hash in place of the literal /workspace/.env. That is enough for an auditor to confirm the right rule fired against the right kind of input, and enough to correlate two occurrences of the same class, without the trail becoming a leak. The test: could someone with read access to the why-trail store reconstruct the secret the refusal protected? If yes, you moved the exposure rather than closed it.

Here is the whole argument in one frame — the same denied write, rendered as a prose refusal (left) and as a structured refusal with a redacted why-trail (right):

PROSE REFUSAL (model-authored)     STRUCTURED REFUSAL + WHY-TRAIL
--------------------------------   --------------------------------
"I'm sorry, I can't help with      { "action": "deny",
 that request. Writing to that       "reason_code": "policy-denied",
 file isn't something I'm able       "matched_rule": "sensitive_path",
 to do. Let me know if there's       "why_trail": [
 another way I can assist!"            {"rule": "approved_workspace",
                                         "matched": false},
  -> not queryable                      {"rule": "sensitive_path",
  -> not stable across sessions           "matched": true,
  -> may drift from the real              "field": "args.path",
     policy decision                      "field_class": "credential-file",
  -> tells an auditor nothing             "value": "<redacted:sha256:9f2a…>"}],
                                       "policy_version": "v3",
                                       "decided_at": "2026-05-02T14:07:22Z" }

The left side is what the user sees, and it is fine as presentation. The right side is what the accountability layer keeps: enumerated, queryable, stable, examinable, and free of the secret it protected. Note what the structured refusal does not do — it does not decide the policy (the policy engine's job) and it does not sign or store the record (the receipt layer's job). It owns one thing: turning a "no" into a reason and a trail that a machine can read and an examiner can trust. That seam is small and sharp — which is the point. Get the enumerated code and the redacted why-trail right, and everything downstream, from an operational guardrails dashboard to a signed audit journal, has something real to read from.