Skip to content
Merged
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
2 changes: 1 addition & 1 deletion axonflow/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down
18 changes: 13 additions & 5 deletions axonflow/decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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`).
Expand Down
4 changes: 3 additions & 1 deletion examples/list_decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down
52 changes: 26 additions & 26 deletions tests/test_decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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": [
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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"}
Expand All @@ -254,15 +254,15 @@ 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

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",
Expand All @@ -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,
}
Expand All @@ -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
Expand All @@ -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",
},
Expand All @@ -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:
Expand All @@ -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"
Expand All @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Loading