Decision Explainability
Available on platform v7.1.0+. The V1.1 forensic fields (policy_version_at_decision, latest_policy_version) require platform v1.1.
Every policy decision AxonFlow makes can be explained after the fact. Given a decision_id, the platform returns the matched policies, rule detail, risk level, override availability, the version of the policy that fired the decision, and a rolling 24-hour count of times the same caller hit the same rule.
This closes the "why was this blocked?" gap. A plugin block stops being a dead end — the user or downstream tooling can pull the full context and decide whether to override, escalate, or accept.
For the listing companion (GET /api/v1/decisions) and the broader decision-oriented execution record framing, see Decision Record.
The shape
The response payload is frozen per ADR-043. Additive fields may appear in future versions (extra fields are tolerated forward), but existing field names, types, and semantics are stable across minor versions.
{
"decision_id": "dec_wf123_step4",
"timestamp": "2026-04-17T12:00:00Z",
"decision": "deny",
"reason": "SQL injection patterns detected in query",
"risk_level": "high",
"policy_matches": [
{
"policy_id": "pol-sqli-detector",
"policy_name": "SQL Injection Detector",
"action": "deny",
"risk_level": "high",
"allow_override": true,
"policy_description": "Blocks SQL injection patterns using keyword + regex detection"
}
],
"matched_rules": [
{
"policy_id": "pol-sqli-detector",
"rule_id": "sqli-union-select",
"rule_text": "Contains UNION SELECT keyword combination",
"matched_on": "query.sql"
}
],
"override_available": true,
"override_existing_id": "ov-f3a81c...",
"historical_hit_count_session": 3,
"policy_source_link": "https://policies.axonflow/sqli-detector",
"tool_signature": "Bash",
"policy_version_at_decision": 3,
"latest_policy_version": 5
}
Every field except decision_id, timestamp, decision, reason, and policy_matches is optional. Consumers that don't find an expected field should treat the absence as "context not available from this platform version" and continue.
V1.1 — Why is this blocked NOW that wasn't 2 days ago?
The two V1.1 fields — policy_version_at_decision and latest_policy_version — exist to answer the most common decision-archaeology question without requiring an extra diff endpoint or cross-referencing the policy timeline by hand.
| Field | Type | Recorded by | Read by | Omitted when |
|---|---|---|---|---|
policy_version_at_decision | int | Audit-write path at decision time. Stored in audit_logs.policy_details->>'policy_version'. | /explain reads back from the same JSONB key. | Decision was made before V1.1 (no version was recorded); decision matched a dynamic policy (no version concept). |
latest_policy_version | int | n/a — looked up at explain time against static_policy_versions. | /explain does the lookup per first matched policy_id. | policy_version_at_decision is omitted; the policy_id has been deleted; the policy was never versioned. |
Reading the two integers together
The two fields are designed to be consumed as a pair:
policy_version_at_decision | latest_policy_version | Operator interpretation |
|---|---|---|
3 | 3 | Policy hasn't changed since the decision. The block is "the same rule that's been there all along." Question shifts from "what changed?" to "why is the user trying this now?" |
3 | 5 | Policy has changed twice since the decision. Pull /api/v1/static-policies/{policy_id}/versions for v3 vs v5 to see what tightened. |
3 | omitted | Policy was deleted. The block was authoritative at the time, but the rule that fired no longer exists in your tenant. |
| omitted | omitted | Decision was either (a) made before V1.1 (no version recorded) or (b) by a dynamic policy. Use the existing reason and matched_rules fields. |
Worked example — operator response
A plugin user reports: "I keep getting blocked on postgres.query. This was working last week."
# 1. Find the most recent block on the affected tool
DECISION_ID=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-H "X-Tenant-ID: $TENANT_ID" \
"https://your-platform/api/v1/decisions?decision=deny&tool_signature=postgres.query&limit=1" \
| jq -r '.decisions[0].decision_id')
# 2. Pull the explanation
EXP=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-H "X-Tenant-ID: $TENANT_ID" \
"https://your-platform/api/v1/decisions/$DECISION_ID/explain")
# 3. Read the two version integers
echo "$EXP" | jq '{
policy_id: .policy_matches[0].policy_id,
policy_version_at_decision: .policy_version_at_decision,
latest_policy_version: .latest_policy_version,
reason: .reason
}'
# {
# "policy_id": "sys_sqli_drop_table",
# "policy_version_at_decision": 3,
# "latest_policy_version": 5,
# "reason": "SQL injection patterns detected"
# }
# 4. Diff v3 → v5 in the policy timeline
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-H "X-Tenant-ID: $TENANT_ID" \
"https://your-platform/api/v1/static-policies/sys_sqli_drop_table/versions" \
| jq '.versions | map(select(.version >= 3 and .version <= 5))'
# Operator now sees what changed between v3 (when the user's previous run succeeded
# under a permissive rule) and v5 (when the rule was tightened). Decide whether to
# revert the policy, grant a Session Override, or escalate.
V1.2 — rule-level diff endpoint
A first-party rule-level diff that takes a decision_id and returns the structured rules_added / rules_removed / rules_changed between policy_version_at_decision and latest_policy_version is on the V1.2 roadmap (GET /api/v1/decisions/:id/policy-version-diff). Until V1.2, compose the existing static-policies version endpoint as shown above — the two integers in V1.1 already give operators a complete forensic answer for static-policy decisions.
API
curl -X GET https://your-platform/api/v1/decisions/{decision_id}/explain \
-u "$CLIENT_ID:$CLIENT_SECRET"
See Decisions API for the full endpoint reference.
SDK usage
Naming is locked across all 5 SDKs (ADR-043 §"SDK parity"):
Go (v5.4.0+):
exp, err := client.ExplainDecision(ctx, "dec_wf123_step4")
if err != nil { return err }
if exp.OverrideAvailable {
// offer the user a governed override action
}
if exp.PolicyVersionAtDecision != 0 && exp.LatestPolicyVersion != exp.PolicyVersionAtDecision {
// policy moved since the decision — surface the version drift to the user
}
Python (v6.4.0+):
exp = await client.explain_decision("dec_wf123_step4")
if exp.override_available:
# offer a governed override
pass
if exp.policy_version_at_decision and exp.latest_policy_version != exp.policy_version_at_decision:
# policy moved since the decision — surface the version drift to the user
pass
TypeScript (v5.4.0+):
const exp = await client.explainDecision("dec_wf123_step4");
if (exp.overrideAvailable) {
// offer a governed override
}
if (exp.policyVersionAtDecision && exp.latestPolicyVersion !== exp.policyVersionAtDecision) {
// policy moved since the decision — surface the version drift to the user
}
Java (v5.4.0+):
DecisionExplanation exp = axonflow.explainDecision("dec_wf123_step4");
if (exp.isOverrideAvailable()) {
// offer a governed override
}
if (exp.getPolicyVersionAtDecision() != null
&& !exp.getPolicyVersionAtDecision().equals(exp.getLatestPolicyVersion())) {
// policy moved since the decision — surface the version drift to the user
}
Rust (v0.2.0+, V1.1 fields require V1.1 client):
let exp = client.explain_decision("dec_wf123_step4").await?;
if exp.override_available {
// offer a governed override
}
if let (Some(at), Some(latest)) = (exp.policy_version_at_decision, exp.latest_policy_version) {
if at != latest {
// policy moved since the decision — surface the version drift to the user
}
}
The two V1.1 fields deserialize as additive optional fields in every SDK — pre-V1.1 client builds will silently ignore them, so the SDK→server compatibility surface is unchanged.
MCP tool parity
The explain_decision MCP tool is served by the agent's MCP server at /api/v1/mcp-server, not by any individual plugin. Every plugin (OpenClaw, Claude Code, Cursor, Codex) points its MCP client at the same agent endpoint, so a single registration on the platform makes the tool available everywhere. Verify with tools/list against the agent:
curl -s -X POST https://your-platform/api/v1/mcp-server \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":"1","method":"tools/list"}' \
| jq '.result.tools[] | .name'
Expected output includes explain_decision, create_override, delete_override, list_overrides alongside the pre-existing governance tools. V1.1 adds list_recent_decisions as the listing companion — same tier-throttled behavior as the underlying GET /api/v1/decisions HTTP endpoint.
Arguments: a single decision_id string. Response: the DecisionExplanation shape above, marshaled as a JSON string inside the MCP tools/call result content. See /docs/governance/overrides for the override-creation tools that pair with this one, and /docs/governance/decisions for the listing companion's MCP wrapper.
Authorization
The caller must either own the decision (the user_email matches) or belong to the same tenant as the decision's originator. Cross-tenant explanation requests return 403.
Admin-level cross-tenant explanation is out of scope for this endpoint — use the audit search API with appropriate admin credentials for that.
Retention
Explanation is bounded by the tier's audit retention:
| Tier | Retention | Scope |
|---|---|---|
| Community | 7 days | Own decisions only |
| Evaluation | 30 days | Own decisions only |
| Enterprise | 365 days | Any decision in org |
| Enterprise+ | Custom | Any decision in org |
Beyond retention, the endpoint returns 404 — no synthesized "we don't know why anymore" placeholder.
Historical hit count
historical_hit_count_session counts how many times the same (policy_id, user_email) combination appeared in audit logs within a rolling 24-hour window from the decision's timestamp.
This is intended for at-a-glance "I keep hitting this rule" awareness. Full cross-decision queries go through audit search.
See also
- Decision Record — list + explain composed into the decision-oriented execution record framing
- Decisions API — full endpoint reference for both list and explain
- Static Policies API —
GET .../{id}/versionsto read what changed betweenpolicy_version_at_decisionandlatest_policy_version - Session Overrides — the action
override_available: trueunlocks - Audit Logging — cross-reference the full lifecycle
Rollout Checklist
Use this page as one layer of the broader governance rollout:
- decide where the rule belongs with Policy Hierarchy
- test the request path with Runtime Request Paths
- connect review workflows to HITL Approval Gates when a block should become a human decision
- compare Community vs Evaluation vs Enterprise when simulation, evidence export, SSO, SCIM, or portal operations become requirements
