diff --git a/CHANGELOG.md b/CHANGELOG.md index 50cc9fb..b956d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,19 @@ 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 @@ -26,6 +39,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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 diff --git a/axonflow/client.py b/axonflow/client.py index 32a4d19..4b605c9 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -47,6 +47,8 @@ RegistrySummary, ) +from urllib.parse import quote + import httpx import structlog from cachetools import TTLCache @@ -75,6 +77,7 @@ ValidateGitProviderRequest, ValidateGitProviderResponse, ) +from axonflow.decisions import DecisionExplanation from axonflow.exceptions import ( AuthenticationError, AxonFlowError, @@ -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. @@ -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( @@ -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, diff --git a/axonflow/decisions.py b/axonflow/decisions.py new file mode 100644 index 0000000..16fff5d --- /dev/null +++ b/axonflow/decisions.py @@ -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 diff --git a/axonflow/types.py b/axonflow/types.py index d1b9b1d..10fcb84 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -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") diff --git a/tests/test_decisions.py b/tests/test_decisions.py new file mode 100644 index 0000000..ee4c6b1 --- /dev/null +++ b/tests/test_decisions.py @@ -0,0 +1,207 @@ +"""Tests for axonflow.decisions (ADR-043 explainability).""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +import pytest + +from axonflow.decisions import DecisionExplanation, ExplainPolicy, ExplainRule + + +class TestDecisionExplanationShape: + """Frozen shape per ADR-043 — these tests pin the contract.""" + + def test_minimum_fields_parse(self) -> None: + exp = DecisionExplanation( + decision_id="dec-1", + timestamp=datetime(2026, 4, 17, tzinfo=timezone.utc), + decision="deny", + ) + assert exp.decision_id == "dec-1" + assert exp.decision == "deny" + assert exp.policy_matches == [] + assert exp.override_available is False + assert exp.historical_hit_count_session == 0 + + def test_full_fields_round_trip(self) -> None: + raw = { + "decision_id": "dec_wf1_step2", + "timestamp": "2026-04-17T12:00:00Z", + "decision": "deny", + "reason": "SQL injection detected", + "risk_level": "high", + "policy_matches": [ + { + "policy_id": "pol-sqli", + "policy_name": "SQL Injection Detector", + "action": "deny", + "risk_level": "high", + "allow_override": True, + "policy_description": "Blocks SQL injection", + } + ], + "matched_rules": [ + { + "policy_id": "pol-sqli", + "rule_id": "rule-1", + "rule_text": "Contains UNION SELECT", + "matched_on": "query.sql", + } + ], + "override_available": True, + "override_existing_id": "ov-abc", + "historical_hit_count_session": 3, + "policy_source_link": "https://policies.axonflow/sqli", + "tool_signature": "Bash", + } + exp = DecisionExplanation.model_validate(raw) + assert exp.decision == "deny" + assert len(exp.policy_matches) == 1 + assert exp.policy_matches[0].policy_id == "pol-sqli" + assert exp.policy_matches[0].allow_override is True + assert len(exp.matched_rules or []) == 1 + assert exp.override_existing_id == "ov-abc" + assert exp.historical_hit_count_session == 3 + assert exp.tool_signature == "Bash" + + def test_extra_fields_are_ignored_for_forward_compat(self) -> None: + # ADR-043: additive fields must not break existing clients. + raw = { + "decision_id": "dec-1", + "timestamp": "2026-04-17T12:00:00Z", + "decision": "allow", + "future_field_we_dont_know_yet": {"nested": True}, + } + exp = DecisionExplanation.model_validate(raw) + assert exp.decision == "allow" + + +class TestExplainPolicy: + def test_defaults(self) -> None: + p = ExplainPolicy(policy_id="p-1") + assert p.policy_id == "p-1" + assert p.allow_override is False + assert p.policy_name is None + + +class TestExplainRule: + def test_minimum(self) -> None: + r = ExplainRule(policy_id="p-1") + assert r.policy_id == "p-1" + assert r.rule_id is None + + +class TestClientExplainDecision: + """Tests for AxonFlowClient.explain_decision.""" + + @pytest.mark.asyncio + async def test_rejects_empty_decision_id(self) -> None: + from axonflow.client import AxonFlow + + client = AxonFlow(endpoint="http://localhost:8080") + with pytest.raises(ValueError, match="decision_id is required"): + await client.explain_decision("") + + @pytest.mark.asyncio + async def test_happy_path(self, monkeypatch: pytest.MonkeyPatch) -> None: + from axonflow.client import AxonFlow + + client = AxonFlow(endpoint="http://localhost:8080") + + captured_args: list[tuple[str, str]] = [] + + async def fake_request( + self: AxonFlow, method: str, path: str, **kwargs: object + ) -> dict[str, object]: + captured_args.append((method, path)) + return { + "decision_id": "dec-1", + "timestamp": "2026-04-17T12:00:00Z", + "decision": "deny", + "reason": "blocked", + "policy_matches": [ + {"policy_id": "p-1", "policy_name": "Test", "allow_override": True} + ], + "override_available": True, + } + + monkeypatch.setattr(AxonFlow, "_orchestrator_request", fake_request) + + exp = await client.explain_decision("dec-1") + assert exp.decision_id == "dec-1" + assert exp.override_available is True + assert exp.policy_matches[0].policy_id == "p-1" + assert captured_args == [("GET", "/api/v1/decisions/dec-1/explain")] + + @pytest.mark.asyncio + async def test_url_encodes_decision_id(self, monkeypatch: pytest.MonkeyPatch) -> None: + from axonflow.client import AxonFlow + + client = AxonFlow(endpoint="http://localhost:8080") + + captured_paths: list[str] = [] + + async def fake_request( + self: AxonFlow, method: str, path: str, **kwargs: object + ) -> dict[str, object]: + captured_paths.append(path) + return { + "decision_id": "a/b", + "timestamp": "2026-04-17T12:00:00Z", + "decision": "allow", + "reason": "", + "policy_matches": [], + "override_available": False, + "historical_hit_count_session": 0, + } + + monkeypatch.setattr(AxonFlow, "_orchestrator_request", fake_request) + + await client.explain_decision("a/b") + assert len(captured_paths) == 1 + assert "a%2Fb" in captured_paths[0] + assert "a/b/explain" not in captured_paths[0] + + @pytest.mark.asyncio + async def test_handles_empty_response(self, monkeypatch: pytest.MonkeyPatch) -> None: + from axonflow.client import AxonFlow + + client = AxonFlow(endpoint="http://localhost:8080") + + async def fake_request(self: AxonFlow, method: str, path: str, **kwargs: object) -> None: + return None + + monkeypatch.setattr(AxonFlow, "_orchestrator_request", fake_request) + + from pydantic import ValidationError + + with pytest.raises(ValidationError): + await client.explain_decision("dec-1") + + +class TestAuditSearchRequestNewFilters: + """The three new filters added per ADR-042/ADR-043 must serialize correctly.""" + + def test_filters_included_when_set(self) -> None: + from axonflow.types import AuditSearchRequest + + req = AuditSearchRequest( + decision_id="dec-1", + policy_name="SQL Injection Detector", + override_id="ov-abc", + ) + dumped = req.model_dump(exclude_none=True) + assert dumped["decision_id"] == "dec-1" + assert dumped["policy_name"] == "SQL Injection Detector" + assert dumped["override_id"] == "ov-abc" + + def test_filters_absent_when_unset(self) -> None: + from axonflow.types import AuditSearchRequest + + req = AuditSearchRequest() + dumped = req.model_dump(exclude_none=True) + assert "decision_id" not in dumped + assert "policy_name" not in dumped + assert "override_id" not in dumped