feat(explanation): normalize deterministic policy explanations#78
Open
sergeyenin wants to merge 3 commits intomainfrom
Open
feat(explanation): normalize deterministic policy explanations#78sergeyenin wants to merge 3 commits intomainfrom
sergeyenin wants to merge 3 commits intomainfrom
Conversation
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
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) <= 1early-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.
- Removed the
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
talon audit showand dashboard evidence/trace views.Test plan
make test-allmake lintmake checkNote
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 sharedexplanation.PolicyRefhelper), 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 showprints explanationstage, 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 newdocs/explanation/explanation-catalog.mdand links it fromevidence-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.