diff --git a/axonflow/client.py b/axonflow/client.py index 181af0a..c138b01 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -2821,7 +2821,7 @@ async def list_decisions( Example: >>> from axonflow.decisions import ListDecisionsOptions - >>> opts = ListDecisionsOptions(decision="deny", limit=10) + >>> opts = ListDecisionsOptions(decision="blocked", limit=10) >>> for d in await client.list_decisions(opts): ... print(d.decision_id, d.decision, d.timestamp) """ diff --git a/axonflow/decisions.py b/axonflow/decisions.py index 048c6f9..0731c37 100644 --- a/axonflow/decisions.py +++ b/axonflow/decisions.py @@ -47,7 +47,11 @@ class DecisionExplanation(BaseModel): with risk level and overridability. * ``matched_rules`` — rule-level detail (optional, populated when the upstream engine supports it). - * ``decision`` — ``allow`` | ``deny`` | ``require_approval``. + * ``decision`` — the canonical audit verdict ``allowed`` | ``blocked`` | + ``redacted`` | ``needs_approval`` | ``error`` (platform 9.0.0+). Pre-9.0.0 + this field used ``allow`` | ``deny`` | ``require_approval``; see the + v8 → v9 migration guide: + https://docs.getaxonflow.com/docs/deployment/v8-to-v9-migration/ * ``reason`` — human-readable reason string. * ``risk_level`` — aggregate risk label for the decision. * ``override_available`` — True iff at least one non-critical policy @@ -74,7 +78,7 @@ class DecisionExplanation(BaseModel): timestamp: datetime policy_matches: list[ExplainPolicy] = Field(default_factory=list) matched_rules: list[ExplainRule] | None = None - decision: str # allow | deny | require_approval + decision: str # allowed | blocked | redacted | needs_approval | error (9.0.0+) reason: str = "" risk_level: str | None = None override_available: bool = False @@ -113,7 +117,7 @@ class DecisionSummary(BaseModel): decision_id: str timestamp: datetime - decision: str # allow | deny | require_approval + decision: str # allowed | blocked | redacted | needs_approval | error (9.0.0+) policy_id: str | None = None tool_signature: str | None = None context: dict[str, str] | None = None @@ -123,8 +127,12 @@ class ListDecisionsOptions(BaseModel): """Optional filters for ``client.list_decisions``. Every field is optional; ``None`` values are omitted from the URL so - the platform applies its tier-default page. ``decision`` must be one - of ``"allow"``, ``"deny"``, or ``"require_approval"`` when set. + the platform applies its tier-default page. ``decision``, when set, must be + one of the canonical audit verdicts ``"allowed"``, ``"blocked"``, + ``"redacted"``, ``"needs_approval"``, or ``"error"`` (platform 9.0.0+). The + pre-9.0.0 values ``"allow"`` / ``"deny"`` / ``"require_approval"`` are + rejected with HTTP 400 by 9.0.0 — see the v8 → v9 migration guide: + https://docs.getaxonflow.com/docs/deployment/v8-to-v9-migration/ ``limit`` is server-capped per tier; over-cap requests yield a 429 with the V1 upgrade envelope (surfaced as :class:`axonflow.exceptions.RateLimitError`). diff --git a/examples/list_decisions.py b/examples/list_decisions.py index e2ea808..5fdde39 100644 --- a/examples/list_decisions.py +++ b/examples/list_decisions.py @@ -12,7 +12,9 @@ Optional filters: - AXONFLOW_LIST_DECISION allow|deny|require_approval + AXONFLOW_LIST_DECISION allowed|blocked|redacted|needs_approval|error + (canonical audit verdicts, platform 9.0.0+; + pre-9.0.0 allow|deny|require_approval now 400) AXONFLOW_LIST_POLICY_ID e.g. sys_sqli_stacked_drop AXONFLOW_LIST_LIMIT integer (server-capped per tier) """ diff --git a/tests/test_decisions.py b/tests/test_decisions.py index 6846120..baca3cb 100644 --- a/tests/test_decisions.py +++ b/tests/test_decisions.py @@ -17,10 +17,10 @@ def test_minimum_fields_parse(self) -> None: exp = DecisionExplanation( decision_id="dec-1", timestamp=datetime(2026, 4, 17, tzinfo=timezone.utc), - decision="deny", + decision="blocked", ) assert exp.decision_id == "dec-1" - assert exp.decision == "deny" + assert exp.decision == "blocked" assert exp.policy_matches == [] assert exp.override_available is False assert exp.historical_hit_count_session == 0 @@ -29,7 +29,7 @@ def test_full_fields_round_trip(self) -> None: raw = { "decision_id": "dec_wf1_step2", "timestamp": "2026-04-17T12:00:00Z", - "decision": "deny", + "decision": "blocked", "reason": "SQL injection detected", "risk_level": "high", "policy_matches": [ @@ -57,7 +57,7 @@ def test_full_fields_round_trip(self) -> None: "tool_signature": "Bash", } exp = DecisionExplanation.model_validate(raw) - assert exp.decision == "deny" + assert exp.decision == "blocked" assert len(exp.policy_matches) == 1 assert exp.policy_matches[0].policy_id == "pol-sqli" assert exp.policy_matches[0].allow_override is True @@ -71,11 +71,11 @@ def test_extra_fields_are_ignored_for_forward_compat(self) -> None: raw = { "decision_id": "dec-1", "timestamp": "2026-04-17T12:00:00Z", - "decision": "allow", + "decision": "allowed", "future_field_we_dont_know_yet": {"nested": True}, } exp = DecisionExplanation.model_validate(raw) - assert exp.decision == "allow" + assert exp.decision == "allowed" class TestExplainPolicy: @@ -119,7 +119,7 @@ async def fake_request( return { "decision_id": "dec-1", "timestamp": "2026-04-17T12:00:00Z", - "decision": "deny", + "decision": "blocked", "reason": "blocked", "policy_matches": [ {"policy_id": "p-1", "policy_name": "Test", "allow_override": True} @@ -150,7 +150,7 @@ async def fake_request( return { "decision_id": "a/b", "timestamp": "2026-04-17T12:00:00Z", - "decision": "allow", + "decision": "allowed", "reason": "", "policy_matches": [], "override_available": False, @@ -225,7 +225,7 @@ def test_minimum_fields_parse(self) -> None: d = DecisionSummary( decision_id="dec-1", timestamp=datetime(2026, 5, 7, 12, 0, 0, tzinfo=timezone.utc), - decision="deny", + decision="blocked", ) assert d.policy_id is None assert d.tool_signature is None @@ -234,12 +234,12 @@ def test_full_fields_round_trip(self) -> None: raw = { "decision_id": "dec-x", "timestamp": "2026-05-07T12:00:00Z", - "decision": "allow", + "decision": "allowed", "policy_id": "pol-default", "tool_signature": "github.status", } d = DecisionSummary.model_validate(raw) - assert d.decision == "allow" + assert d.decision == "allowed" assert d.policy_id == "pol-default" # extra='ignore' accepts arbitrary unknown fields raw_extra = {**raw, "policy_version": 7, "future_field": "shrug"} @@ -254,7 +254,7 @@ def test_summary_context_absent_is_none(self) -> None: d = DecisionSummary( decision_id="dec-noctx", timestamp=datetime(2026, 5, 30, tzinfo=timezone.utc), - decision="allow", + decision="allowed", ) assert d.context is None @@ -262,7 +262,7 @@ def test_summary_context_round_trip(self) -> None: raw = { "decision_id": "dec-ctx", "timestamp": "2026-05-30T12:00:00Z", - "decision": "deny", + "decision": "blocked", "context": { "x_ai_agent": "refund-bot", "x_session_id": "sess-42", @@ -283,7 +283,7 @@ def test_explanation_full_context_and_truncated_flag(self) -> None: raw = { "decision_id": "dec-x", "timestamp": "2026-05-30T12:00:00Z", - "decision": "deny", + "decision": "blocked", "context": {"x_ai_agent": "a", "x_session_id": "s"}, "context_truncated": True, } @@ -295,7 +295,7 @@ def test_explanation_context_defaults_none(self) -> None: exp = DecisionExplanation( decision_id="dec-1", timestamp=datetime(2026, 5, 30, tzinfo=timezone.utc), - decision="allow", + decision="allowed", ) assert exp.context is None assert exp.context_truncated is None @@ -316,21 +316,21 @@ async def test_happy_path(self, httpx_mock) -> None: { "decision_id": "dec-1", "timestamp": "2026-05-07T12:00:00Z", - "decision": "deny", + "decision": "blocked", "policy_id": "pol-sqli", "tool_signature": "postgres.query", }, { "decision_id": "dec-2", "timestamp": "2026-05-07T11:00:00Z", - "decision": "allow", + "decision": "allowed", "policy_id": "pol-default", "tool_signature": "github.status", }, { "decision_id": "dec-3", "timestamp": "2026-05-07T10:00:00Z", - "decision": "require_approval", + "decision": "needs_approval", "policy_id": "pol-amount", "tool_signature": "stripe.charge", }, @@ -341,7 +341,7 @@ async def test_happy_path(self, httpx_mock) -> None: got = await client.list_decisions() assert len(got) == 3 assert got[0].decision_id == "dec-1" - assert got[2].decision == "require_approval" + assert got[2].decision == "needs_approval" @pytest.mark.asyncio async def test_filter_serialization(self, httpx_mock) -> None: @@ -354,7 +354,7 @@ async def test_filter_serialization(self, httpx_mock) -> None: url=( "http://localhost:8080/api/v1/decisions?" "since=2026-05-07T00%3A00%3A00Z&" - "decision=deny&" + "decision=blocked&" "policy_id=pol-sqli&" "tool_signature=postgres.query&" "limit=25" @@ -364,7 +364,7 @@ async def test_filter_serialization(self, httpx_mock) -> None: client = AxonFlow(endpoint="http://localhost:8080") opts = ListDecisionsOptions( since=datetime(2026, 5, 7, 0, 0, 0, tzinfo=timezone.utc), - decision="deny", + decision="blocked", policy_id="pol-sqli", tool_signature="postgres.query", limit=25, @@ -379,11 +379,11 @@ async def test_omits_unset_filters(self, httpx_mock) -> None: # Only decision is set; URL must omit the others entirely. httpx_mock.add_response( method="GET", - url="http://localhost:8080/api/v1/decisions?decision=deny", + url="http://localhost:8080/api/v1/decisions?decision=blocked", json={"decisions": []}, ) client = AxonFlow(endpoint="http://localhost:8080") - await client.list_decisions(ListDecisionsOptions(decision="deny")) + await client.list_decisions(ListDecisionsOptions(decision="blocked")) @pytest.mark.asyncio async def test_429_upgrade_envelope(self, httpx_mock) -> None: @@ -474,7 +474,7 @@ async def test_forward_compat_unknown_fields(self, httpx_mock) -> None: { "decision_id": "dec-fwd", "timestamp": "2026-05-07T12:00:00Z", - "decision": "deny", + "decision": "blocked", "policy_id": "pol-x", "tool_signature": "tool-x", "policy_version": 7, @@ -505,6 +505,6 @@ def test_empty_options_returns_empty(self) -> None: def test_partial_options_omit_none_fields(self) -> None: from axonflow.client import _build_list_decisions_query - opts = ListDecisionsOptions(decision="deny", limit=7) + opts = ListDecisionsOptions(decision="blocked", limit=7) qs = _build_list_decisions_query(opts) - assert qs == "decision=deny&limit=7" + assert qs == "decision=blocked&limit=7"