Skip to content

feat(explanation): normalize deterministic policy explanations#78

Open
sergeyenin wants to merge 3 commits intomainfrom
feat/deterministic-policy-explanations-v2
Open

feat(explanation): normalize deterministic policy explanations#78
sergeyenin wants to merge 3 commits intomainfrom
feat/deterministic-policy-explanations-v2

Conversation

@sergeyenin
Copy link
Copy Markdown
Contributor

@sergeyenin sergeyenin commented Apr 13, 2026

Summary

  • Normalize deterministic explanation generation across runner, gateway, and MCP evidence paths so policy outcomes are consistently represented as structured explanation items.
  • Improve user-facing surfaces by showing richer explanation metadata in talon audit show and dashboard evidence/trace views.
  • Add a complete explanation catalog manual and regression tests to keep explanation output deterministic and auditable.

Test plan

  • make test-all
  • make lint
  • make check

Note

Medium Risk
Medium risk because it changes how evidence explanations[] are generated/normalized across runner, gateway, and MCP paths, which can affect audit outputs and downstream consumers expecting prior reason/trigger formats.

Overview
Evidence records now always populate deterministic explanations[] across gateway and MCP flows (and reuse a shared explanation.PolicyRef helper), with additional normalization to make triggers deterministic (sorted/deduped comma-separated token lists) and a rule-table-driven mapping from legacy free-text reasons to stable explanation codes.

User-facing surfaces are updated to show richer explanation metadata: talon audit show prints explanation stage, the dashboard list view appends the primary explanation code to the allowed/denied status, and the trace modal includes stage/trigger/policy ref. Documentation adds a new docs/explanation/explanation-catalog.md and links it from evidence-store.md, and tests are extended to assert non-empty explanations and stage fields in evidence/CLI/API outputs.

Reviewed by Cursor Bugbot for commit c81959c. Configure here.

Unify structured explanation generation across runner, gateway, and MCP evidence paths and surface actionable explanation metadata consistently in CLI and dashboard views. Add an explanation catalog manual and regression coverage to keep explanation output deterministic and auditable.
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Deduplication fails when tokens reduce to one
    • Removed the len(tokens) <= 1 early-return guard so that sort+join always runs on the deduplicated tokens slice, correctly returning the single unique token instead of the original duplicated string.
Preview (82c0a98641)
diff --git a/docs/explanation/evidence-store.md b/docs/explanation/evidence-store.md
--- a/docs/explanation/evidence-store.md
+++ b/docs/explanation/evidence-store.md
@@ -2,6 +2,8 @@
 
 How Talon creates, signs, stores, and exports tamper-proof audit records.
 
+For a complete list of explanation codes and remediation guidance, see [explanation-catalog.md](./explanation-catalog.md).
+
 ## Overview
 
 Every interaction that passes through Talon generates an **evidence record** --

diff --git a/docs/explanation/explanation-catalog.md b/docs/explanation/explanation-catalog.md
new file mode 100644
--- /dev/null
+++ b/docs/explanation/explanation-catalog.md
@@ -1,0 +1,67 @@
+# Talon Explanation Catalog
+
+This manual lists all explanation codes emitted by Talon evidence records.
+
+Determinism guarantees:
+- Explanations are generated by rule-based code only (no LLM generation).
+- Items are sorted and deduplicated before persistence.
+- Primary explanation is always the first sorted explanation item.
+
+## Explanation Item Schema
+
+Every explanation item has:
+- `code`: stable machine-readable explanation code
+- `decision`: `allow`, `deny`, `modify`, `filter`, or `failure`
+- `stage`: enforcement stage (for example `policy_evaluation`, `tool_execution`, `output_validation`)
+- `reason`: canonical human-readable reason
+- `trigger` (optional): normalized triggering signal
+- `fix` (optional): remediation guidance
+- `policy_ref` (optional): canonical policy reference (`policy:<version_identity>`)
+- `version_identity` (optional): immutable policy version identity
+
+## Policy and Execution Explanations
+
+| Code | Decision | Typical stage | Trigger format | Meaning | Operator fix |
+| --- | --- | --- | --- | --- | --- |
+| `POLICY_ALLOWED` | `allow` | `policy_evaluation` / `tool_execution` | policy action or tool name | Request allowed by policy. | None required. |
+| `POLICY_DENIED` | `deny` | `policy_evaluation` | free-text reason token | Request blocked by policy. | Inspect policy reasons and align request/policy. |
+| `POLICY_DENIED_PII_INPUT` | `deny` | `policy_evaluation` | comma-separated PII entity list (sorted) | Request blocked because input PII was detected. | Remove or mask sensitive input data. |
+| `POLICY_DENIED_PII_OUTPUT` | `deny` | `output_validation` | output PII token/list | Request blocked because output PII was detected. | Tighten prompt/tool outputs or enable redaction path. |
+| `POLICY_DENIED_COST` | `deny` | `policy_evaluation` | cost/budget reason | Request blocked by cost policy limits. | Reduce token usage or increase cost limits. |
+| `POLICY_DENIED_ROUTING` | `deny` | `policy_evaluation` | routing/provider reason | Request blocked by model routing policy. | Select an allowed model/provider for the data tier. |
+| `POLICY_DENIED_TOOL` | `deny` | `tool_execution` | tool name or policy reason | Request blocked by tool access policy. | Use allowed tool or extend allowlist. |
+| `POLICY_DENIED_HOOK` | `deny` | `pre_execution` / hook stage | hook deny reason | Request blocked by governance hook. | Review hook policy and approval gates. |
+| `POLICY_DENIED_CIRCUIT_BREAKER` | `deny` | `pre_execution` | circuit breaker reason | Request blocked because circuit breaker is open. | Resolve repeated failures/denials and retry after cooldown. |
+| `POLICY_DENIED_EARLY_TERMINATION` | `deny` | `pre_execution` | early termination reason | Run terminated by governance pre-check. | Inspect failure reason and rerun after correction. |
+| `POLICY_MODIFIED` | `modify` | `policy_evaluation` | modification token | Request was modified by policy before execution. | Usually no action; review modifications if unexpected. |
+| `POLICY_FILTERED` | `filter` | `output_validation` | filtering reason (for example `output_pii_redacted`) | Request output was filtered/redacted by policy. | None if expected; otherwise adjust filtering policy. |
+| `EXECUTION_FAILED` | `failure` | `execution` / `tool_execution` | error string | Request failed during execution. | Inspect dependency/tool/provider error and retry. |
+| `LEGACY_REASON_UNMIGRATED` | `deny` (default) | `policy_evaluation` | legacy reason text | Legacy free-text reason not yet mapped to specific code. | Add explicit mapping and structured fact for this path. |
+
+## Graph Governance Explanations
+
+| Code | Decision | Typical stage | Meaning | Operator fix |
+| --- | --- | --- | --- | --- |
+| `GRAPH_RUN_ALLOWED` | `allow` | `graph_run` | Graph run completed within governance limits. | None required. |
+| `GRAPH_ITERATION_LIMIT_DENY` | `deny` | `graph_step` | Graph denied: iteration limit exceeded. | Increase `max_iterations` or reduce graph steps. |
+| `GRAPH_COST_LIMIT_DENY` | `deny` | `graph_step` | Graph denied: run cost limit exceeded. | Increase `max_cost_per_run` or optimize step usage. |
+| `GRAPH_RETRY_LIMIT_DENY` | `deny` | `graph_step` | Graph retry denied: node retry limit exceeded. | Increase `max_retries_per_node` or fix node failures. |
+| `GRAPH_TOOL_DENY` | `deny` | `graph_step` | Graph tool call denied by allowlist/governance. | Add tool to allowed capabilities if compliant. |
+
+## Surface Mapping
+
+- CLI: `talon audit show` prints explanation `code`, `decision`, `stage`, `reason`, `trigger`, `fix`, `policy_ref`, `version_identity`.
+- Dashboard:
+  - Evidence table status includes primary explanation code.
+  - Trace modal shows primary explanation reason and remediation metadata.
+- API:
+  - `GET /v1/evidence` includes `primary_explanation_code` and `primary_explanation_reason`.
+  - `GET /v1/evidence/{id}` and `GET /v1/evidence/{id}/trace` include full `explanations[]`.
+
+## Maintenance Rule
+
+When introducing a new explanation code or changing explanation semantics:
+1. Add/update the code in `internal/explanation/explanation.go`.
+2. Add/update tests in `internal/explanation/explanation_test.go`.
+3. Update this catalog in the same PR.
+4. Verify CLI and dashboard rendering for the new code path.

diff --git a/internal/agent/runner.go b/internal/agent/runner.go
--- a/internal/agent/runner.go
+++ b/internal/agent/runner.go
@@ -952,11 +952,7 @@
 }
 
 func explanationPolicyRef(policyVersion string) string {
-	pv := strings.TrimSpace(policyVersion)
-	if pv == "" {
-		return ""
-	}
-	return "policy:" + pv
+	return explanation.PolicyRef(policyVersion)
 }
 
 func buildPolicyDecisionFacts(decision *policy.Decision, stage string) []explanation.Fact {

diff --git a/internal/cmd/audit.go b/internal/cmd/audit.go
--- a/internal/cmd/audit.go
+++ b/internal/cmd/audit.go
@@ -613,6 +613,9 @@
 		for i := range ev.Explanations {
 			ex := ev.Explanations[i]
 			fmt.Fprintf(w, "  [%d] %s | %s | %s\n", i+1, ex.Code, ex.Decision, ex.Reason)
+			if ex.Stage != "" {
+				fmt.Fprintf(w, "      Stage: %s\n", ex.Stage)
+			}
 			if ex.Trigger != "" {
 				fmt.Fprintf(w, "      Trigger: %s\n", ex.Trigger)
 			}

diff --git a/internal/cmd/audit_test.go b/internal/cmd/audit_test.go
--- a/internal/cmd/audit_test.go
+++ b/internal/cmd/audit_test.go
@@ -11,6 +11,7 @@
 	"github.com/stretchr/testify/require"
 
 	"github.com/dativo-io/talon/internal/evidence"
+	"github.com/dativo-io/talon/internal/explanation"
 )
 
 func TestAuditCmd_HasSubcommands(t *testing.T) {
@@ -242,6 +243,34 @@
 	assert.Contains(t, out, "tampered")
 }
 
+func TestRenderAuditShow_ExplanationStage(t *testing.T) {
+	var buf bytes.Buffer
+	ev := &evidence.Evidence{
+		ID:             "req_stage",
+		Timestamp:      time.Now(),
+		TenantID:       "default",
+		AgentID:        "bot",
+		InvocationType: "manual",
+		PolicyDecision: evidence.PolicyDecision{
+			Allowed: false,
+			Action:  "deny",
+		},
+		Execution:  evidence.Execution{},
+		AuditTrail: evidence.AuditTrail{},
+		Compliance: evidence.Compliance{},
+		Explanations: []explanation.Item{{
+			Code:     explanation.CodePolicyDeniedTool,
+			Decision: explanation.DecisionDeny,
+			Stage:    "tool_execution",
+			Reason:   "Request blocked by tool access policy.",
+		}},
+	}
+
+	renderAuditShow(&buf, ev, true)
+	out := buf.String()
+	assert.Contains(t, out, "Stage: tool_execution")
+}
+
 func TestRenderVerifyResult_WithSummary(t *testing.T) {
 	var buf bytes.Buffer
 	ev := &evidence.Evidence{

diff --git a/internal/explanation/explanation.go b/internal/explanation/explanation.go
--- a/internal/explanation/explanation.go
+++ b/internal/explanation/explanation.go
@@ -1,6 +1,7 @@
 package explanation
 
 import (
+	"regexp"
 	"sort"
 	"strings"
 )
@@ -160,7 +161,7 @@
 		f.Code = strings.TrimSpace(f.Code)
 		f.Decision = strings.TrimSpace(f.Decision)
 		f.Stage = normalizeStage(f.Stage)
-		f.Trigger = strings.TrimSpace(f.Trigger)
+		f.Trigger = normalizeTrigger(f.Trigger)
 		f.Fix = strings.TrimSpace(f.Fix)
 		f.PolicyRef = strings.TrimSpace(f.PolicyRef)
 		f.VersionIdentity = strings.TrimSpace(f.VersionIdentity)
@@ -179,6 +180,44 @@
 	return out
 }
 
+var triggerTokenPattern = regexp.MustCompile(`^[a-zA-Z0-9_.:-]+$`)
+
+func normalizeTrigger(trigger string) string {
+	t := strings.TrimSpace(trigger)
+	if t == "" || !strings.Contains(t, ",") {
+		return t
+	}
+	parts := strings.Split(t, ",")
+	tokens := make([]string, 0, len(parts))
+	seen := make(map[string]struct{}, len(parts))
+	for i := range parts {
+		p := strings.TrimSpace(parts[i])
+		if p == "" {
+			continue
+		}
+		// Preserve free-form sentences; normalize only token lists.
+		if !triggerTokenPattern.MatchString(p) {
+			return t
+		}
+		if _, ok := seen[p]; ok {
+			continue
+		}
+		seen[p] = struct{}{}
+		tokens = append(tokens, p)
+	}
+	sort.Strings(tokens)
+	return strings.Join(tokens, ",")
+}
+
+// PolicyRef returns the canonical policy reference token used in explanations.
+func PolicyRef(versionIdentity string) string {
+	v := strings.TrimSpace(versionIdentity)
+	if v == "" {
+		return ""
+	}
+	return "policy:" + v
+}
+
 var reasonByCode = map[string]string{
 	CodePolicyAllowed:           "Request allowed by policy.",
 	CodePolicyDenied:            "Request blocked by policy.",
@@ -264,37 +303,48 @@
 
 func codeFromReason(reason string, decision string) string {
 	r := strings.ToLower(strings.TrimSpace(reason))
-	switch {
-	case strings.Contains(r, "input contains pii"), strings.Contains(r, "block_on_pii"):
-		return CodePolicyDeniedPIIInput
-	case strings.Contains(r, "output contains pii"), strings.Contains(r, "block_on_output_pii"):
-		return CodePolicyDeniedPIIOutput
-	case strings.Contains(r, "cost"), strings.Contains(r, "budget"):
-		return CodePolicyDeniedCost
-	case strings.Contains(r, "routing"):
-		return CodePolicyDeniedRouting
-	case strings.Contains(r, "tool"), strings.Contains(r, "forbidden"):
-		return CodePolicyDeniedTool
-	case strings.Contains(r, "hook"):
-		return CodePolicyDeniedHook
-	case strings.Contains(r, "circuit_breaker"), strings.Contains(r, "circuit breaker"):
-		return CodePolicyDeniedCircuit
-	case strings.Contains(r, "early_termination"):
-		return CodePolicyDeniedEarlyTerm
-	case strings.Contains(r, "max_iterations"):
-		return CodeGraphIterationLimitDeny
-	case strings.Contains(r, "max_cost_per_run"):
-		return CodeGraphCostLimitDeny
-	case strings.Contains(r, "max_retries_per_node"):
-		return CodeGraphRetryLimitDeny
-	default:
-		if decision == DecisionAllow {
-			return CodePolicyAllowed
+	for i := range reasonRules {
+		rule := reasonRules[i]
+		if containsAny(r, rule.markers...) {
+			return rule.code
 		}
-		return CodeLegacyReasonUnmigrated
 	}
+	if decision == DecisionAllow {
+		return CodePolicyAllowed
+	}
+	return CodeLegacyReasonUnmigrated
 }
 
+type reasonRule struct {
+	markers []string
+	code    string
+}
+
+var reasonRules = []reasonRule{
+	{markers: []string{"input contains pii", "block_on_pii", "pii block", "input pii"}, code: CodePolicyDeniedPIIInput},
+	{markers: []string{"output contains pii", "block_on_output_pii"}, code: CodePolicyDeniedPIIOutput},
+	{markers: []string{"cost", "budget"}, code: CodePolicyDeniedCost},
+	{markers: []string{"routing", "provider not allowed"}, code: CodePolicyDeniedRouting},
+	{markers: []string{"tool", "forbidden"}, code: CodePolicyDeniedTool},
+	{markers: []string{"hook"}, code: CodePolicyDeniedHook},
+	{markers: []string{"circuit_breaker", "circuit breaker"}, code: CodePolicyDeniedCircuit},
+	{markers: []string{"early_termination"}, code: CodePolicyDeniedEarlyTerm},
+	{markers: []string{"output_pii_redacted", "output pii"}, code: CodePolicyFiltered},
+	{markers: []string{"policy evaluation error", "retrieval error", "eval error"}, code: CodeExecutionFailed},
+	{markers: []string{"max_iterations"}, code: CodeGraphIterationLimitDeny},
+	{markers: []string{"max_cost_per_run"}, code: CodeGraphCostLimitDeny},
+	{markers: []string{"max_retries_per_node"}, code: CodeGraphRetryLimitDeny},
+}
+
+func containsAny(input string, markers ...string) bool {
+	for i := range markers {
+		if strings.Contains(input, markers[i]) {
+			return true
+		}
+	}
+	return false
+}
+
 func defaultFix(code string) string {
 	switch code {
 	case CodePolicyDeniedPIIInput, CodePolicyDeniedPIIOutput:

diff --git a/internal/explanation/explanation_test.go b/internal/explanation/explanation_test.go
--- a/internal/explanation/explanation_test.go
+++ b/internal/explanation/explanation_test.go
@@ -33,3 +33,20 @@
 	assert.Equal(t, CodePolicyDeniedPIIInput, items[0].Code)
 	assert.Equal(t, CodePolicyDeniedRouting, items[1].Code)
 }
+
+func TestExplanation_NormalizesTriggerTokenLists(t *testing.T) {
+	items := BuildFromFacts([]Fact{{
+		Code:     CodePolicyDeniedPIIInput,
+		Decision: DecisionDeny,
+		Stage:    "policy_evaluation",
+		Trigger:  "EMAIL,IBAN,EMAIL",
+	}})
+	requireLen := 1
+	assert.Len(t, items, requireLen)
+	assert.Equal(t, "EMAIL,IBAN", items[0].Trigger)
+}
+
+func TestExplanation_PolicyRef(t *testing.T) {
+	assert.Equal(t, "", PolicyRef(""))
+	assert.Equal(t, "policy:1.0.0:sha256:abc", PolicyRef("1.0.0:sha256:abc"))
+}

diff --git a/internal/gateway/evidence.go b/internal/gateway/evidence.go
--- a/internal/gateway/evidence.go
+++ b/internal/gateway/evidence.go
@@ -7,6 +7,7 @@
 	"github.com/google/uuid"
 
 	"github.com/dativo-io/talon/internal/evidence"
+	"github.com/dativo-io/talon/internal/explanation"
 )
 
 // RecordGatewayEvidenceParams holds all inputs for a gateway evidence record.
@@ -41,14 +42,15 @@
 	ToolsFiltered           []string
 	ToolsForwarded          []string
 	// Semantic cache (set when response was served from cache)
-	CacheHit        bool
-	CacheEntryID    string
-	CacheSimilarity float64
-	CostSaved       float64
-	AgentReasoning  string
-	RetryAttempt    string // X-Talon-Retry-Attempt header value; empty when not a retry
-	Stage           string // "generation", "judge", or "commit"
-	CandidateIndex  int
+	CacheHit         bool
+	CacheEntryID     string
+	CacheSimilarity  float64
+	CostSaved        float64
+	AgentReasoning   string
+	RetryAttempt     string // X-Talon-Retry-Attempt header value; empty when not a retry
+	Stage            string // "generation", "judge", or "commit"
+	CandidateIndex   int
+	ExplanationFacts []explanation.Fact
 }
 
 // RecordGatewayEvidence creates and stores a signed evidence record for a gateway request.
@@ -115,5 +117,31 @@
 	if !params.PolicyAllowed {
 		ev.PolicyDecision.Action = "deny"
 	}
+	facts := append([]explanation.Fact(nil), params.ExplanationFacts...)
+	if len(facts) == 0 {
+		stage := params.Stage
+		if stage == "" {
+			stage = "policy_evaluation"
+		}
+		facts = explanation.BuildLegacyFacts(
+			params.PolicyAllowed,
+			ev.PolicyDecision.Action,
+			params.PolicyReasons,
+			stage,
+			explanation.PolicyRef(params.PolicyVersion),
+			params.PolicyVersion,
+		)
+		if params.OutputPIIDetected {
+			facts = append(facts, explanation.Fact{
+				Code:            explanation.CodePolicyDeniedPIIOutput,
+				Decision:        explanation.DecisionDeny,
+				Stage:           "output_validation",
+				Trigger:         "output_pii_detected",
+				PolicyRef:       explanation.PolicyRef(params.PolicyVersion),
+				VersionIdentity: params.PolicyVersion,
+			})
+		}
+	}
+	ev.Explanations = explanation.BuildFromFacts(facts)
 	return store.Store(ctx, ev)
 }

diff --git a/internal/gateway/evidence_test.go b/internal/gateway/evidence_test.go
--- a/internal/gateway/evidence_test.go
+++ b/internal/gateway/evidence_test.go
@@ -82,6 +82,9 @@
 	assert.Equal(t, []string{"search_web", "delete_all_messages"}, ev.ToolGovernance.ToolsRequested)
 	assert.Equal(t, []string{"delete_all_messages"}, ev.ToolGovernance.ToolsFiltered)
 	assert.Equal(t, []string{"search_web"}, ev.ToolGovernance.ToolsForwarded)
+	require.NotEmpty(t, ev.Explanations, "gateway evidence must include deterministic explanations")
+	assert.NotEmpty(t, ev.Explanations[0].Code)
+	assert.NotEmpty(t, ev.Explanations[0].Stage)
 }
 
 func TestEvidenceSanitization(t *testing.T) {

diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go
--- a/internal/gateway/gateway.go
+++ b/internal/gateway/gateway.go
@@ -7,6 +7,7 @@
 	"io"
 	"net/http"
 	"strconv"
+	"strings"
 	"sync"
 	"time"
 
@@ -19,6 +20,7 @@
 	"github.com/dativo-io/talon/internal/cache"
 	"github.com/dativo-io/talon/internal/classifier"
 	"github.com/dativo-io/talon/internal/evidence"
+	"github.com/dativo-io/talon/internal/explanation"
 	"github.com/dativo-io/talon/internal/llm"
 	"github.com/dativo-io/talon/internal/secrets"
 	"github.com/dativo-io/talon/internal/session"
@@ -771,6 +773,7 @@
 		RetryAttempt:            retryAttemptFromContext(ctx),
 		Stage:                   stageFromContext(ctx),
 		CandidateIndex:          candidateIndexFromContext(ctx),
+		ExplanationFacts:        buildGatewayExplanationFacts(allowed, reasons, outputPIIDetected, outputPIITypes, stageFromContext(ctx)),
 	}
 	if toolResult != nil {
 		params.ToolsRequested = toolResult.Requested
@@ -786,6 +789,34 @@
 	return RecordGatewayEvidence(ctx, g.evidenceStore, params)
 }
 
+func buildGatewayExplanationFacts(allowed bool, reasons []string, outputPIIDetected bool, outputPIITypes []string, stage string) []explanation.Fact {
+	s := strings.TrimSpace(stage)
+	if s == "" {
+		s = "policy_evaluation"
+	}
+	facts := explanation.BuildLegacyFacts(allowed, decisionAction(allowed), reasons, s, "", "")
+	if outputPIIDetected {
+		trigger := "output_pii_detected"
+		if len(outputPIITypes) > 0 {
+			trigger = strings.Join(outputPIITypes, ",")
+		}
+		facts = append(facts, explanation.Fact{
+			Code:     explanation.CodePolicyDeniedPIIOutput,
+			Decision: explanation.DecisionDeny,
+			Stage:    "output_validation",
+			Trigger:  trigger,
+		})
+	}
+	return facts
+}
+
+func decisionAction(allowed bool) string {
+	if allowed {
+		return "allow"
+	}
+	return "deny"
+}
+
 func agentReasoningFromContext(ctx context.Context) string {
 	v := ctx.Value(gatewayAgentReasoningKey)
 	if s, ok := v.(string); ok {

diff --git a/internal/mcp/openclaw_incident_test.go b/internal/mcp/openclaw_incident_test.go
--- a/internal/mcp/openclaw_incident_test.go
+++ b/internal/mcp/openclaw_incident_test.go
@@ -201,6 +201,8 @@
 		piiRecorded := false
 		for _, ev := range records {
 			if ev.InvocationType == "proxy_pii_redaction" {
+				assert.NotEmpty(t, ev.Explanations, "proxy evidence should include deterministic explanations")
+				assert.NotEmpty(t, ev.Explanations[0].Code)
 				piiRecorded = true
 			}
 		}

diff --git a/internal/mcp/proxy.go b/internal/mcp/proxy.go
--- a/internal/mcp/proxy.go
+++ b/internal/mcp/proxy.go
@@ -16,6 +16,7 @@
 
 	"github.com/dativo-io/talon/internal/classifier"
 	"github.com/dativo-io/talon/internal/evidence"
+	"github.com/dativo-io/talon/internal/explanation"
 	"github.com/dativo-io/talon/internal/otel"
 	"github.com/dativo-io/talon/internal/policy"
 	"github.com/dativo-io/talon/internal/requestctx"
@@ -442,9 +443,64 @@
 			Error:       reason,
 		},
 	}
+	ev.Explanations = explanation.BuildFromFacts(proxyExplanationFacts(eventType, reason, toolName, allowed))
 	_ = h.evidenceStore.Store(ctx, ev)
 }
 
+func proxyExplanationFacts(eventType, reason, toolName string, allowed bool) []explanation.Fact {
+	trigger := strings.TrimSpace(reason)
+	if trigger == "" {
+		trigger = strings.TrimSpace(toolName)
+	}
+	switch eventType {
+	case "proxy_tool_blocked":
+		return []explanation.Fact{{
+			Code:     explanation.CodePolicyDeniedTool,
+			Decision: explanation.DecisionDeny,
+			Stage:    "tool_execution",
+			Trigger:  trigger,
+		}}
+	case "proxy_pii_eval_error":
+		return []explanation.Fact{{
+			Code:     explanation.CodeExecutionFailed,
+			Decision: explanation.DecisionFailure,
+			Stage:    "policy_evaluation",
+			Trigger:  trigger,
+		}}
+	case "proxy_pii_redaction":
+		if !allowed {
+			return []explanation.Fact{{
+				Code:     explanation.CodePolicyDeniedPIIInput,
+				Decision: explanation.DecisionDeny,
+				Stage:    "policy_evaluation",
+				Trigger:  trigger,
+			}}
+		}
+		return []explanation.Fact{{
+			Code:     explanation.CodePolicyModified,
+			Decision: explanation.DecisionModify,
+			Stage:    "policy_evaluation",
+			Trigger:  "pii_redacted",
+			Fix:      "No action required. Sensitive values were redacted before forwarding.",
+		}}
+	default:
+		if reason == "output_pii_redacted" {
+			return []explanation.Fact{{
+				Code:     explanation.CodePolicyFiltered,
+				Decision: explanation.DecisionFilter,
+				Stage:    "output_validation",
+				Trigger:  reason,
+			}}
+		}
+		return []explanation.Fact{{
+			Code:     explanation.CodePolicyAllowed,
+			Decision: explanation.DecisionAllow,
+			Stage:    "tool_execution",
+			Trigger:  trigger,
+		}}
+	}
+}
+
 func paramsToMap(raw json.RawMessage) map[string]interface{} {
 	if len(raw) == 0 {
 		return nil

diff --git a/internal/mcp/server.go b/internal/mcp/server.go
--- a/internal/mcp/server.go
+++ b/internal/mcp/server.go
@@ -14,6 +14,7 @@
 
 	"github.com/dativo-io/talon/internal/agent/tools"
 	"github.com/dativo-io/talon/internal/evidence"
+	"github.com/dativo-io/talon/internal/explanation"
 	"github.com/dativo-io/talon/internal/otel"
 	"github.com/dativo-io/talon/internal/policy"
 	"github.com/dativo-io/talon/internal/requestctx"
@@ -176,6 +177,36 @@
 			msg = decision.Reasons[0]
 		}
 		span.SetAttributes(attribute.String("policy.deny", msg))
+		denyEv := &evidence.Evidence{
+			ID:              "req_" + uuid.New().String()[:8],
+			CorrelationID:   "mcp_" + uuid.New().String()[:8],
+			Timestamp:       time.Now(),
+			TenantID:        tenantID,
+			AgentID:         agentID,
+			InvocationType:  "mcp",
+			RequestSourceID: "mcp",
+			PolicyDecision: evidence.PolicyDecision{
+				Allowed:       false,
+				Action:        decision.Action,
+				Reasons:       decision.Reasons,
+				PolicyVersion: decision.PolicyVersion,
+			},
+			Execution: evidence.Execution{
+				ToolsCalled: []string{params.Name},
+				Error:       msg,
+			},
+			Explanations: explanation.BuildFromFacts(explanation.BuildLegacyFacts(
+				false,
+				decision.Action,
+				decision.Reasons,
+				"policy_evaluation",
+				explanation.PolicyRef(decision.PolicyVersion),
+				decision.PolicyVersion,
+			)),
+		}
+		if storeErr := h.evidenceStore.Store(ctx, denyEv); storeErr != nil {
+			span.RecordError(storeErr)
+		}
 		return &jsonrpcResponse{JSONRPC: jsonrpcVersion, ID: req.ID, Error: &rpcError{Code: codeServerError, Message: msg}}
 	}
 
@@ -214,8 +245,24 @@
 			DurationMS:  duration,
 		},
 	}
+	ev.Explanations = explanation.BuildFromFacts([]explanation.Fact{{
+		Code:            explanation.CodePolicyAllowed,
+		Decision:        explanation.DecisionAllow,
+		Stage:           "tool_execution",
+		Trigger:         params.Name,
+		PolicyRef:       explanation.PolicyRef(decision.PolicyVersion),
+		VersionIdentity: decision.PolicyVersion,
+	}})
 	if execErr != nil {
 		ev.Execution.Error = execErr.Error()
+		ev.Explanations = explanation.BuildFromFacts([]explanation.Fact{{
+			Code:            explanation.CodeExecutionFailed,
+			Decision:        explanation.DecisionFailure,
+			Stage:           "tool_execution",
+			Trigger:         execErr.Error(),
+			PolicyRef:       explanation.PolicyRef(decision.PolicyVersion),
+			VersionIdentity: decision.PolicyVersion,
+		}})
 	}
 	if storeErr := h.evidenceStore.Store(ctx, ev); storeErr != nil {
 		span.RecordError(storeErr)

diff --git a/internal/server/server_test.go b/internal/server/server_test.go
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -1692,6 +1692,7 @@
 	first := explanations[0].(map[string]interface{})
 	assert.NotEmpty(t, first["code"])
 	assert.NotEmpty(t, first["reason"])
+	assert.NotEmpty(t, first["stage"])
 	assert.NotEmpty(t, first["version_identity"])
 }
 

diff --git a/web/dashboard.html b/web/dashboard.html
--- a/web/dashboard.html
+++ b/web/dashboard.html
@@ -478,7 +478,10 @@
           (d.entries || []).forEach(function(ev) {
             var tr = document.createElement('tr');
             var time = ev.timestamp ? new Date(ev.timestamp).toLocaleString() : '—';
-            var status = ev.allowed ? '<span class="status-ok">allowed</span>' : '<span class="status-deny">denied</span>';
+            var primaryCode = ev.primary_explanation_code || '';
+            var statusText = ev.allowed ? 'allowed' : 'denied';
+            if (primaryCode) statusText += ' (' + primaryCode + ')';
+            var status = ev.allowed ? '<span class="status-ok">' + escapeHtml(statusText) + '</span>' : '<span class="status-deny">' + escapeHtml(statusText) + '</span>';
             var cost = ev.cost != null ? ev.cost.toFixed(4) : '—';
             var id = ev.id || '';
             var idEsc = escapeHtml(id || '—');
@@ -544,6 +547,9 @@
                 if (explanations.length > 0) {
                   var primary = explanations[0];
                   lines.push('Primary explanation: ' + (primary.code || '—') + ' - ' + (primary.reason || '—'));
+                  if (primary.stage) lines.push('Stage: ' + primary.stage);
+                  if (primary.trigger) lines.push('Trigger: ' + primary.trigger);
+                  if (primary.policy_ref) lines.push('Policy ref: ' + primary.policy_ref);
                   if (primary.version_identity) lines.push('Version: ' + primary.version_identity);
                   if (primary.fix) lines.push('Fix: ' + primary.fix);
                 }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit c81959c. Configure here.

cursoragent and others added 2 commits April 13, 2026 16:11
…to one

normalizeTrigger returned the original un-deduplicated string when
deduplication reduced tokens to ≤1 unique values. For example,
"EMAIL,EMAIL" would pass through unchanged instead of becoming
"EMAIL". Remove the early-return guard so sort+join always runs,
which correctly handles 0- and 1-element slices.
Standardize explanation stages with canonical constants and normalize legacy aliases to prevent cross-surface drift. Correct MCP request-side PII explanations to reflect detection (not redaction) and avoid raw execution error leakage in explanation triggers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants