From be3cbba6d981d12b778c280bdf8c7177e334bc91 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 17 Apr 2026 22:37:51 +0200 Subject: [PATCH 1/4] feat: decisions.explain + audit search filter parity (Plugin Batch 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Python SDK half of ADR-043 + ADR-042. New: - axonflow.decisions module with DecisionExplanation, ExplainPolicy, ExplainRule Pydantic models. Shape frozen per ADR-043. - AxonFlow.explain_decision(decision_id) async method calling GET /api/v1/decisions/:id/explain. Rejects empty decision_id with ValueError; relies on Pydantic validation for malformed responses. - AuditSearchRequest gains three optional filters: decision_id, policy_name, override_id. Back-compat — old callers ignore them, new callers include them only when set. Version: 6.3.0 -> 6.4.0. pyproject.toml + _version.py updated. Tests (10 new, all passing): - DecisionExplanation shape: minimum parse, full round-trip, forward-compat with unknown extra fields. - ExplainPolicy/ExplainRule defaults. - Client explain_decision: rejects empty id, happy path (monkeypatched orchestrator call with URL assertion), empty-response ValidationError. - AuditSearchRequest: three new filters included when set, absent when unset (exclude_none behavior). Companion to platform v7.1.0 (axonflow-enterprise PR #1605) and Go SDK v5.4.0 (axonflow-sdk-go PR #122). --- CHANGELOG.md | 27 ++++++ axonflow/_version.py | 2 +- axonflow/client.py | 46 +++++++++++ axonflow/decisions.py | 72 ++++++++++++++++ axonflow/types.py | 5 ++ pyproject.toml | 2 +- tests/test_decisions.py | 176 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 axonflow/decisions.py create mode 100644 tests/test_decisions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 50cc9fb..641b228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to the AxonFlow Python SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.5.0] - 2026-04-18 + +### Added + +- **`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. + +### Compatibility + +Companion to platform v7.1.0. Back-compatible — extra filters pass through +when unset; server-side filtering only happens on v7.1.0+ platforms. The +`DecisionExplanation` model accepts additive future fields via Pydantic's +default extra-fields-ignore behavior. + +--- + ## [6.4.0] - 2026-04-18 ### Added diff --git a/axonflow/_version.py b/axonflow/_version.py index ac5b762..404dfff 100644 --- a/axonflow/_version.py +++ b/axonflow/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the AxonFlow SDK version.""" -__version__ = "6.4.0" +__version__ = "6.5.0" diff --git a/axonflow/client.py b/axonflow/client.py index 32a4d19..e734465 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -123,6 +123,7 @@ UpdateStaticPolicyRequest, ) from axonflow.telemetry import send_telemetry_ping +from axonflow.decisions import DecisionExplanation from axonflow.types import ( AuditLogEntry, AuditQueryOptions, @@ -2278,6 +2279,12 @@ async def search_audit_logs( 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 @@ -2317,6 +2324,45 @@ 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) + + response = await self._orchestrator_request( + "GET", + f"/api/v1/decisions/{decision_id}/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..f644dec --- /dev/null +++ b/axonflow/decisions.py @@ -0,0 +1,72 @@ +"""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 typing import List, Optional + +from pydantic import BaseModel, Field + + +class ExplainPolicy(BaseModel): + """A policy reference inside a decision explanation.""" + + policy_id: str + policy_name: Optional[str] = None + action: Optional[str] = None + risk_level: Optional[str] = None # low | medium | high | critical + allow_override: bool = False + policy_description: Optional[str] = None + + +class ExplainRule(BaseModel): + """Rule-level detail inside a decision explanation.""" + + policy_id: str + rule_id: Optional[str] = None + rule_text: Optional[str] = None + matched_on: Optional[str] = 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: Optional[List[ExplainRule]] = None + decision: str # allow | deny | require_approval + reason: str = "" + risk_level: Optional[str] = None + override_available: bool = False + override_existing_id: Optional[str] = None + historical_hit_count_session: int = 0 + policy_source_link: Optional[str] = None + tool_signature: Optional[str] = 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/pyproject.toml b/pyproject.toml index 1385c47..dc9f816 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "axonflow" -version = "6.4.0" +version = "6.5.0" description = "AxonFlow Python SDK - Enterprise AI Governance in 3 Lines of Code" readme = "README.md" license = {text = "MIT"} diff --git a/tests/test_decisions.py b/tests/test_decisions.py new file mode 100644 index 0000000..2367cc3 --- /dev/null +++ b/tests/test_decisions.py @@ -0,0 +1,176 @@ +"""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_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 From 98d0b961fb70073b1e334a263fb1fabe2b4e4c16 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 17 Apr 2026 23:23:27 +0200 Subject: [PATCH 2/4] fix: URL-escape decision_id in explain_decision path Review finding: decision_id was interpolated raw into the URL path. IDs that contain "/" or "?" would break the request. Use urllib.parse.quote with safe="" to escape every reserved character. Test added: test_url_encodes_decision_id verifies "a/b" hits path containing "a%2Fb" and never the raw "a/b/explain". Parity with Go v5.4.0 post-review fix (same bug, same fix, same test). --- axonflow/client.py | 7 ++++++- tests/test_decisions.py | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/axonflow/client.py b/axonflow/client.py index e734465..6ecccc5 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -2354,9 +2354,14 @@ async def explain_decision(self, decision_id: str) -> DecisionExplanation: msg = "decision_id is required" raise ValueError(msg) + # Path-escape the decision ID. ADR-043 doesn't constrain the + # identifier format — IDs containing "/" or "?" would break the URL. + from urllib.parse import quote + encoded = quote(decision_id, safe="") + response = await self._orchestrator_request( "GET", - f"/api/v1/decisions/{decision_id}/explain", + f"/api/v1/decisions/{encoded}/explain", ) if not isinstance(response, dict): diff --git a/tests/test_decisions.py b/tests/test_decisions.py index 2367cc3..67ae512 100644 --- a/tests/test_decisions.py +++ b/tests/test_decisions.py @@ -133,6 +133,33 @@ async def fake_request(self: AxonFlow, method: str, path: str, **kwargs: object) 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 From 2ae34dbe90bffd57441b7183728e074c0596f143 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 18 Apr 2026 00:20:48 +0200 Subject: [PATCH 3/4] fix: ruff lint + changelog polish for Plugin Batch 1 PR Ruff lint green: - PLR0912 too-many-branches on search_audit_logs: extracted the body construction into a module-level _build_audit_search_body() helper. Same behavior, below the branch threshold. - PLC0415 import-not-at-top-level: moved urllib.parse.quote to the top-of-file import block. - I001 import-order: ruff auto-fix applied. - E501 line-too-long in tests/test_decisions.py: wrapped the fake_request signature across multiple lines. Changelog polish (per maintainer feedback): - Release date normalized to 2026-04-18. - Removed internal ADR-043 reference from the user-facing changelog. --- axonflow/client.py | 59 ++++++++++++++++++++++++----------------- axonflow/decisions.py | 27 +++++++++---------- tests/test_decisions.py | 8 ++++-- 3 files changed, 54 insertions(+), 40 deletions(-) diff --git a/axonflow/client.py b/axonflow/client.py index 6ecccc5..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, @@ -123,7 +126,6 @@ UpdateStaticPolicyRequest, ) from axonflow.telemetry import send_telemetry_ping -from axonflow.decisions import DecisionExplanation from axonflow.types import ( AuditLogEntry, AuditQueryOptions, @@ -298,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. @@ -2267,26 +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.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 + body = _build_audit_search_body(request) if self._config.debug: self._logger.debug( @@ -2354,9 +2366,8 @@ async def explain_decision(self, decision_id: str) -> DecisionExplanation: msg = "decision_id is required" raise ValueError(msg) - # Path-escape the decision ID. ADR-043 doesn't constrain the - # identifier format — IDs containing "/" or "?" would break the URL. - from urllib.parse import quote + # 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( diff --git a/axonflow/decisions.py b/axonflow/decisions.py index f644dec..16fff5d 100644 --- a/axonflow/decisions.py +++ b/axonflow/decisions.py @@ -8,7 +8,6 @@ from __future__ import annotations from datetime import datetime -from typing import List, Optional from pydantic import BaseModel, Field @@ -17,20 +16,20 @@ class ExplainPolicy(BaseModel): """A policy reference inside a decision explanation.""" policy_id: str - policy_name: Optional[str] = None - action: Optional[str] = None - risk_level: Optional[str] = None # low | medium | high | critical + policy_name: str | None = None + action: str | None = None + risk_level: str | None = None # low | medium | high | critical allow_override: bool = False - policy_description: Optional[str] = None + policy_description: str | None = None class ExplainRule(BaseModel): """Rule-level detail inside a decision explanation.""" policy_id: str - rule_id: Optional[str] = None - rule_text: Optional[str] = None - matched_on: Optional[str] = None + rule_id: str | None = None + rule_text: str | None = None + matched_on: str | None = None class DecisionExplanation(BaseModel): @@ -60,13 +59,13 @@ class DecisionExplanation(BaseModel): decision_id: str timestamp: datetime - policy_matches: List[ExplainPolicy] = Field(default_factory=list) - matched_rules: Optional[List[ExplainRule]] = None + policy_matches: list[ExplainPolicy] = Field(default_factory=list) + matched_rules: list[ExplainRule] | None = None decision: str # allow | deny | require_approval reason: str = "" - risk_level: Optional[str] = None + risk_level: str | None = None override_available: bool = False - override_existing_id: Optional[str] = None + override_existing_id: str | None = None historical_hit_count_session: int = 0 - policy_source_link: Optional[str] = None - tool_signature: Optional[str] = None + policy_source_link: str | None = None + tool_signature: str | None = None diff --git a/tests/test_decisions.py b/tests/test_decisions.py index 67ae512..ee4c6b1 100644 --- a/tests/test_decisions.py +++ b/tests/test_decisions.py @@ -112,7 +112,9 @@ async def test_happy_path(self, monkeypatch: pytest.MonkeyPatch) -> None: captured_args: list[tuple[str, str]] = [] - async def fake_request(self: AxonFlow, method: str, path: str, **kwargs: object) -> dict[str, object]: + 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", @@ -141,7 +143,9 @@ async def test_url_encodes_decision_id(self, monkeypatch: pytest.MonkeyPatch) -> captured_paths: list[str] = [] - async def fake_request(self: AxonFlow, method: str, path: str, **kwargs: object) -> dict[str, object]: + async def fake_request( + self: AxonFlow, method: str, path: str, **kwargs: object + ) -> dict[str, object]: captured_paths.append(path) return { "decision_id": "a/b", From dfb025bb28d2529a0d03990dbc7a0790a8437660 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 18 Apr 2026 12:20:58 +0200 Subject: [PATCH 4/4] chore: merge explain/audit-filter features into combined v6.4.0 Prior rebase mistakenly introduced a separate v6.5.0 section for the Plugin Batch 1 SDK features. Since v6.4.0 has not been released yet, these changes belong in the same v6.4.0 release as the execution-boundary / checkpoint features from the other session. Merged them into the single v6.4.0 changelog entry and reverted version strings in pyproject.toml and axonflow/_version.py. --- CHANGELOG.md | 45 ++++++++++++++++++++------------------------ axonflow/_version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 641b228..b956d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,20 @@ All notable changes to the AxonFlow Python SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [6.5.0] - 2026-04-18 +## [6.4.0] - 2026-04-18 ### Added +- **Execution boundary semantics** — `RetryPolicy` enum with `IDEMPOTENT` + (default) and `REEVALUATE` values. Step gate requests accept `retry_policy` + to control cached vs fresh evaluation behavior. +- **Step gate response metadata** — `cached` (bool) and `decision_source` + (str) fields on `StepGateResponse` indicate decision provenance. +- **Workflow checkpoints** — `get_checkpoints(workflow_id)` lists step-gate + checkpoints. `resume_from_checkpoint(workflow_id, checkpoint_id)` resumes + 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, @@ -23,36 +33,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 matched by a specific policy; `override_id` to reconstruct an override's full lifecycle. -### Compatibility - -Companion to platform v7.1.0. Back-compatible — extra filters pass through -when unset; server-side filtering only happens on v7.1.0+ platforms. The -`DecisionExplanation` model accepts additive future fields via Pydantic's -default extra-fields-ignore behavior. - ---- - -## [6.4.0] - 2026-04-18 - -### Added - -- **Execution boundary semantics** — `RetryPolicy` enum with `IDEMPOTENT` - (default) and `REEVALUATE` values. Step gate requests accept `retry_policy` - to control cached vs fresh evaluation behavior. -- **Step gate response metadata** — `cached` (bool) and `decision_source` - (str) fields on `StepGateResponse` indicate decision provenance. -- **Workflow checkpoints** — `get_checkpoints(workflow_id)` lists step-gate - checkpoints. `resume_from_checkpoint(workflow_id, checkpoint_id)` resumes - from a specific checkpoint with fresh policy evaluation (Enterprise). -- **Checkpoint types** — `Checkpoint`, `CheckpointListResponse`, and - `ResumeFromCheckpointResponse` models. - ### 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 diff --git a/axonflow/_version.py b/axonflow/_version.py index 404dfff..ac5b762 100644 --- a/axonflow/_version.py +++ b/axonflow/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the AxonFlow SDK version.""" -__version__ = "6.5.0" +__version__ = "6.4.0" diff --git a/pyproject.toml b/pyproject.toml index dc9f816..1385c47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "axonflow" -version = "6.5.0" +version = "6.4.0" description = "AxonFlow Python SDK - Enterprise AI Governance in 3 Lines of Code" readme = "README.md" license = {text = "MIT"}