Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
from a specific checkpoint with fresh policy evaluation (Enterprise).
- **Checkpoint types** — `Checkpoint`, `CheckpointListResponse`, and
`ResumeFromCheckpointResponse` models.
- **`AxonFlow.explain_decision(decision_id)`** — fetches the full explanation for a
previously-made policy decision via `GET /api/v1/decisions/:id/explain`.
Returns a `DecisionExplanation` with matched policies, risk level, reason,
override availability, existing override ID (if any), and a rolling-24h
session hit count for the matched rule. Shape is frozen; additive-only
fields ensure forward compatibility.
- **`DecisionExplanation`, `ExplainPolicy`, `ExplainRule`** — new Pydantic
models exported from `axonflow.decisions`.
- **`AuditSearchRequest.decision_id`, `policy_name`, `override_id`** — three
new optional filter fields on `search_audit_logs`. Use `decision_id` to
gather every record tied to one decision; `policy_name` to find everything
matched by a specific policy; `override_id` to reconstruct an override's
full lifecycle.

### Fixed

- `step_gate()` now correctly passes `retry_policy` in the request body
and populates `cached`/`decision_source` in the response. Previously
these fields were defined on the model but not wired through the client.

### Compatibility

Companion to platform v7.1.0. Works against plugin releases (OpenClaw v1.3.0+,
Claude Code v0.5.0+, Cursor v0.5.0+, Codex v0.4.0+) that surface the
`DecisionExplanation` shape. Audit filter fields pass through when unset;
server-side filtering activates on v7.1.0+ platforms. The `DecisionExplanation`
model accepts additive future fields via Pydantic's default extra-fields-ignore
behavior.

## [6.3.0] - 2026-04-09

### Added
Expand Down
90 changes: 76 additions & 14 deletions axonflow/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
RegistrySummary,
)

from urllib.parse import quote

import httpx
import structlog
from cachetools import TTLCache
Expand Down Expand Up @@ -75,6 +77,7 @@
ValidateGitProviderRequest,
ValidateGitProviderResponse,
)
from axonflow.decisions import DecisionExplanation
from axonflow.exceptions import (
AuthenticationError,
AxonFlowError,
Expand Down Expand Up @@ -297,6 +300,35 @@ def has_capability(self, name: str) -> bool:
return any(c.name == name for c in self.capabilities)


def _build_audit_search_body(request: AuditSearchRequest) -> dict[str, Any]:
"""Build the POST /api/v1/audit/search body from an AuditSearchRequest.

Extracted from ``AxonFlow.search_audit_logs`` to keep that method under
the branch-count lint threshold. Only non-empty / non-default fields are
emitted — the wire contract is "omit fields you don't care about".
"""
body: dict[str, Any] = {"limit": request.limit}
if request.user_email:
body["user_email"] = request.user_email
if request.client_id:
body["client_id"] = request.client_id
if request.start_time:
body["start_time"] = request.start_time.isoformat()
if request.end_time:
body["end_time"] = request.end_time.isoformat()
if request.request_type:
body["request_type"] = request.request_type
if request.decision_id:
body["decision_id"] = request.decision_id
if request.policy_name:
body["policy_name"] = request.policy_name
if request.override_id:
body["override_id"] = request.override_id
if request.offset > 0:
body["offset"] = request.offset
return body


class AxonFlow:
"""Main AxonFlow client for AI governance.

Expand Down Expand Up @@ -2266,20 +2298,7 @@ async def search_audit_logs(
if request is None:
request = AuditSearchRequest()

# Build request body with only non-None values
body: dict[str, Any] = {"limit": request.limit}
if request.user_email:
body["user_email"] = request.user_email
if request.client_id:
body["client_id"] = request.client_id
if request.start_time:
body["start_time"] = request.start_time.isoformat()
if request.end_time:
body["end_time"] = request.end_time.isoformat()
if request.request_type:
body["request_type"] = request.request_type
if request.offset > 0:
body["offset"] = request.offset
body = _build_audit_search_body(request)

if self._config.debug:
self._logger.debug(
Expand Down Expand Up @@ -2317,6 +2336,49 @@ async def search_audit_logs(
offset=response.get("offset", request.offset),
)

async def explain_decision(self, decision_id: str) -> DecisionExplanation:
"""Fetch the full explanation for a previously-made policy decision.

Implements ADR-043. Calls ``GET /api/v1/decisions/:id/explain`` and
returns a :class:`DecisionExplanation` with matched policies, risk
level, override availability, and a rolling-24h session hit count.

The caller must either own the decision (user_email match) or belong
to the same tenant as the decision's originator.

Args:
decision_id: The global decision identifier returned in the
original step gate or policy evaluation response.

Returns:
A DecisionExplanation (frozen shape per ADR-043).

Raises:
ValueError: If ``decision_id`` is empty.

Example:
>>> exp = await client.explain_decision("dec_wf123_step4")
>>> if exp.override_available:
... # offer the user a governed override action
... pass
"""
if not decision_id:
msg = "decision_id is required"
raise ValueError(msg)

# Path-escape the decision ID. The data contract doesn't constrain
# the identifier format — IDs containing "/" or "?" would break the URL.
encoded = quote(decision_id, safe="")

response = await self._orchestrator_request(
"GET",
f"/api/v1/decisions/{encoded}/explain",
)

if not isinstance(response, dict):
response = {}
return DecisionExplanation.model_validate(response)

async def get_audit_logs_by_tenant(
self,
tenant_id: str,
Expand Down
71 changes: 71 additions & 0 deletions axonflow/decisions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Decision explainability types and helpers.

Implements ADR-043 (Explainability Data Contract). The DecisionExplanation
shape is frozen; additive-only changes are allowed; renames/removals require
a major version bump.
"""

from __future__ import annotations

from datetime import datetime

from pydantic import BaseModel, Field


class ExplainPolicy(BaseModel):
"""A policy reference inside a decision explanation."""

policy_id: str
policy_name: str | None = None
action: str | None = None
risk_level: str | None = None # low | medium | high | critical
allow_override: bool = False
policy_description: str | None = None


class ExplainRule(BaseModel):
"""Rule-level detail inside a decision explanation."""

policy_id: str
rule_id: str | None = None
rule_text: str | None = None
matched_on: str | None = None


class DecisionExplanation(BaseModel):
"""Canonical payload returned by ``client.explain_decision``.

Shape frozen per ADR-043. Fields:

* ``decision_id`` — the global decision identifier.
* ``timestamp`` — when the decision was made.
* ``policy_matches`` — every policy that contributed to the decision,
with risk level and overridability.
* ``matched_rules`` — rule-level detail (optional, populated when the
upstream engine supports it).
* ``decision`` — ``allow`` | ``deny`` | ``require_approval``.
* ``reason`` — human-readable reason string.
* ``risk_level`` — aggregate risk label for the decision.
* ``override_available`` — True iff at least one non-critical policy
with ``allow_override=True`` matched.
* ``override_existing_id`` — populated when an active override already
exists for this caller and policy scope.
* ``historical_hit_count_session`` — how many times the caller has hit
the same rule in the rolling 24-hour session window.
* ``policy_source_link`` — URL to the policy definition (optional).
* ``tool_signature`` — the tool signature the decision was scoped to,
if any.
"""

decision_id: str
timestamp: datetime
policy_matches: list[ExplainPolicy] = Field(default_factory=list)
matched_rules: list[ExplainRule] | None = None
decision: str # allow | deny | require_approval
reason: str = ""
risk_level: str | None = None
override_available: bool = False
override_existing_id: str | None = None
historical_hit_count_session: int = 0
policy_source_link: str | None = None
tool_signature: str | None = None
5 changes: 5 additions & 0 deletions axonflow/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,11 @@ class AuditSearchRequest(BaseModel):
start_time: datetime | None = Field(default=None, description="Start of time range")
end_time: datetime | None = Field(default=None, description="End of time range")
request_type: str | None = Field(default=None, description="Filter by request type")
# ADR-043: explainability + audit cross-reference filters.
decision_id: str | None = Field(default=None, description="Filter by decision ID")
policy_name: str | None = Field(default=None, description="Filter by matched policy name")
# ADR-042: override lifecycle reconstruction.
override_id: str | None = Field(default=None, description="Filter by session-override ID")
limit: int = Field(default=100, ge=1, le=1000, description="Max results")
offset: int = Field(default=0, ge=0, description="Pagination offset")

Expand Down
Loading
Loading