Skip to main content

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.

FieldTypeRecorded byRead byOmitted when
policy_version_at_decisionintAudit-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_versionintn/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_decisionlatest_policy_versionOperator interpretation
33Policy 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?"
35Policy has changed twice since the decision. Pull /api/v1/static-policies/{policy_id}/versions for v3 vs v5 to see what tightened.
3omittedPolicy was deleted. The block was authoritative at the time, but the rule that fired no longer exists in your tenant.
omittedomittedDecision 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:

TierRetentionScope
Community7 daysOwn decisions only
Evaluation30 daysOwn decisions only
Enterprise365 daysAny decision in org
Enterprise+CustomAny 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 APIGET .../{id}/versions to read what changed between policy_version_at_decision and latest_policy_version
  • Session Overrides — the action override_available: true unlocks
  • Audit Logging — cross-reference the full lifecycle

Rollout Checklist

Use this page as one layer of the broader governance rollout: