Skip to main content

Refusals

Route: /refusals on the dashboard. Backed by: GET /api/audit/refusals (list) and GET /api/audit/refusals/{id} (detail).
Refusals dashboard surface — a held call expanded to its full refusal envelope
Every tool call that ended with status='refused' lands on this surface. The page renders a chronological feed of incidents; expanding any row reveals the full envelope detail inline (what the agent saw, what reason fired, which categories the policy intersected). This is the surface for two distinct workflows:
  1. Operator triage. A user complained the agent kept saying “I don’t have access to that.” Open Refusals, find the relevant row, see exactly what category list intersected the policy. Decide whether to widen --pii-block or fix the question.
  2. Recovery quality review. Read the envelopes SchemaBrain returned. Were the error.recovery suggestions actually useful? Did the agent self-correct on the next turn?

What each row carries

Each refusal row in the feed shows the same fields the underlying mcp_audit row carries:
FieldMeaning
idThe audit row’s monotonic ID.
occurred_atISO 8601 timestamp.
tool_nameThe MCP tool that was called (get_metric, describe_column, etc.).
refusal_reasonOne of pii_blocked, allowlist_violation, fragment_unsafe, cost_cap_exceeded, ambiguous_resolution, schema_drift.
pii_categoriesThe category set the envelope returned. On pii_blocked refusals, this is the blocked set — the intersection of policy and request that fired.
cost_classrefused for this surface.
The detail panel adds the full envelope body: status, the structured error.recovery payload the agent received (the suggested tool + args to recover with), and the chain_hash that anchors the row in the audit log. (On a pii_blocked refusal the recovery rides error.recovery; the sibling follow_up_hints field is null.)
The audit row’s pii_categories records only the blocked set — the categories the policy intersected. The full attempted (propagated) set the agent’s plan would have touched lives only on the PiiBlockedError exception object and the event bus. See audit-chain — What this is not for the forensic implication and the roadmap item.

What “refused” means upstream

A refusal is not an error. It is a Charter-conformant envelope with status='refused' and a structured error.recovery block (the suggested tool + args). The agent receives enough information to either:
  • Pick a different tool path that does not touch the blocked categories.
  • Ask the user to confirm the access is intended.
  • Surface a clean “this is policy” message instead of a hallucinated retry.
Each refusal reason maps to a recovery path documented in Structured Recovery. Today, pii_blocked is the reason the engine actually emits; the other five are reserved, schema-valid enum values whose recovery paths land as those checks ship.

How to populate this view

In normal operation, refused rows accumulate as agents use the MCP server. To verify the surface works end-to-end on a fresh install (or to seed a demo), use the bundled seeding script:
python scripts/seed_refused_audit_row.py --store-path ./schemabrain.db --source-id <your-source-id>
This writes one synthetic pii_blocked row to your local store. Refresh the dashboard tab and the row appears at the top of the feed. Alternatively, configure a strict --pii-block policy on serve and ask an agent for a metric that touches a blocked category:
schemabrain serve \
    --url-env DATABASE_URL \
    --store-path ./schemabrain.db \
    --pii-block credential,payment_card,government_id,contact,health

Empty state

The page distinguishes “no refusals because the agent never tried” from “no refusals because the policy is empty.” If the feed is empty, that is a good signal — the agent has been able to do its work without crossing a blocked category. The empty-state message in the UI says exactly that. If you expect refusals and see none, double-check:
  • The --pii-block flag on the running serve process — pass --pii-block '' and it explicitly blocks nothing.
  • That schemabrain index ran without --no-pii-classify — without tags, the policy has nothing to act on.
  • That the indexed source actually has columns the classifier would flag — heuristic classification has false negatives for cryptically-named columns.

Cross-reference into the audit chain

Every refusal is also a row in the Audit Viewer. The two surfaces share IDs — opening row 42 on the Refusals feed and row 42 on the Audit Viewer drawer renders the same underlying row, different facets:
  • Refusals highlights the envelope the agent received.
  • Audit Viewer highlights the chain_hash and tamper-evidence story.

Structured recovery

The envelope shape every refused call returns.

PII taxonomy

The 12 categories and the catastrophic-leak subset.

Audit Viewer

Where the refused row lives in the chain.

schemabrain serve --pii-block

The flag that produces refusals in the first place.