Skip to content

Commit 188a074

Browse files
feat(decisions): add list_decisions method [skip-runtime-e2e] (#186)
* feat(decisions): add list_decisions client method (#1982) Python SDK piece of the 5-SDK γ fanout for axonflow-enterprise#1982. Companion to explain_decision; both implement the ADR-043 decision record contract with the same parity discipline. ## Added - AxonFlow.list_decisions(opts: ListDecisionsOptions | None) -> list[DecisionSummary] hitting GET /api/v1/decisions. - DecisionSummary (pydantic, extra='ignore') — slim 5-field row. policy_id + tool_signature are Optional because pre-α1 audit rows + dynamic-only blocks may not populate them. - ListDecisionsOptions — every filter Optional[X]=None; None values omitted from the URL so the platform applies tier defaults. - Extended exceptions.RateLimitError with new optional kwargs (limit_type, tier, upgrade) plus a new UpgradeInfo type. Backwards compatible — existing daily-quota callers keep their signature. - _build_list_decisions_query helper at module scope (testable in isolation; used by list_decisions). - examples/list_decisions.py — operator example mirroring examples/quickstart.py shape; surfaces RateLimitError.upgrade. ## Tests 10 new contract tests in tests/test_decisions.py: - DecisionSummaryShape (minimum + full + extra-fields-ignored) - happy path (3-row payload) - filter serialization (every filter lands in URL) - empty/partial options omit None fields - 429 → RateLimitError with parsed envelope - 429 with malformed body → RateLimitError without upgrade context (never silently succeed) - 401 surfaces as AxonFlowError - forward-compat — additive unknown fields on summaries + envelope - _build_list_decisions_query unit (None / empty / partial) pytest tests/test_decisions.py: 23 passed (including the 13 pre-existing). ## Runtime-e2e proof Against setup-e2e-testing.sh enterprise stack on axonflow-enterprise@feat/decisions-list-endpoint: $ source /tmp/axonflow-e2e-env.sh $ AXONFLOW_LIST_LIMIT=5 python examples/list_decisions.py === Recent decisions (2) === 2026-05-07T14:13:38.464545+00:00 deny f6029a9b-... policy=- tool=- 2026-05-07T14:13:38.453107+00:00 deny 2575a3fe-... policy=- tool=- ⛔ Do not merge — V1.1 release window. Signed-off-by: Saurabh Jain <saurabhjain1592@gmail.com> * fix(lint): drop Greek session labels + fix line-length + var name (#1982) Ruff trips: - RUF002/RUF003 on Greek-letter session labels (α1, γ) in docstrings and comments. Replace with plain "list_decisions" / "pre-V1.1". - E741 ambiguous variable `l` in examples/list_decisions.py — rename to `lim`. - E501 long lines on the 429 envelope wording in tests + example. Wrap into parenthesized string concatenation. 23 unit tests still green. Signed-off-by: Saurabh Jain <saurabhjain1592@gmail.com> * fix(lint): refresh falsey-clobber baseline + harden 429 envelope parse (#1982) CI's falsey-clobber lint compares findings against a baseline keyed by absolute line numbers. Inserting list_decisions (~106 lines) shifted every pre-existing pattern below it down by the same amount; the linter therefore reported 18 NEW findings + 18 stale baseline entries (same set, different lines). This commit: - Refreshes .lint_baselines/falsey_clobber.json so the existing patterns are recognised at their new line numbers (no behaviour change — same 18 patterns, same files; only positions updated). - Rewrites the new list_decisions 429 path to use `is None` / isinstance() guards instead of `or` fallbacks, so the new code contributes ZERO falsey-clobber findings to the baseline. Behaviour is identical: missing/non-string error -> "rate limit exceeded"; missing/non-numeric limit/remaining -> 0; non-list decisions -> []. Signed-off-by: Saurabh Jain <saurabhjain1592@gmail.com> * fix(lint): apply ruff format to list_decisions 429 path (#1982) Ruff formatter wanted the conditional remaining=... expression on a single line; my hand-formatted multi-line version triggered `ruff format --check` failure in CI. Behaviour is unchanged — only whitespace normalised. Signed-off-by: Saurabh Jain <saurabhjain1592@gmail.com> * fix(lint): re-refresh falsey-clobber baseline after ruff reformat (#1982) Ruff formatter collapsed a 3-line conditional in list_decisions onto a single line, shifting line 6208's `executions or []` pattern up to line 6206. The previous baseline refresh keyed it to :6208; refresh again so the post-format positions match. No behaviour change. Same 65 pre-existing findings, same files; only positions adjusted. Signed-off-by: Saurabh Jain <saurabhjain1592@gmail.com> --------- Signed-off-by: Saurabh Jain <saurabhjain1592@gmail.com>
1 parent f8efb15 commit 188a074

6 files changed

Lines changed: 580 additions & 22 deletions

File tree

.lint_baselines/falsey_clobber.json

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,24 @@
2222
"axonflow/adapters/tool_wrapper.py:190:20",
2323
"axonflow/adapters/tool_wrapper.py:208:20",
2424
"axonflow/adapters/tool_wrapper.py:220:20",
25-
"axonflow/client.py:1065:16",
26-
"axonflow/client.py:1142:16",
27-
"axonflow/client.py:1614:37",
28-
"axonflow/client.py:1655:18",
29-
"axonflow/client.py:1713:37",
30-
"axonflow/client.py:2231:24",
31-
"axonflow/client.py:2252:33",
32-
"axonflow/client.py:2253:31",
33-
"axonflow/client.py:2265:25",
34-
"axonflow/client.py:2326:28",
35-
"axonflow/client.py:2367:69",
36-
"axonflow/client.py:286:14",
37-
"axonflow/client.py:291:24",
38-
"axonflow/client.py:292:20",
39-
"axonflow/client.py:488:44",
40-
"axonflow/client.py:6074:25",
41-
"axonflow/client.py:799:20",
42-
"axonflow/client.py:885:20",
25+
"axonflow/client.py:1100:16",
26+
"axonflow/client.py:1177:16",
27+
"axonflow/client.py:1649:37",
28+
"axonflow/client.py:1690:18",
29+
"axonflow/client.py:1748:37",
30+
"axonflow/client.py:2266:24",
31+
"axonflow/client.py:2287:33",
32+
"axonflow/client.py:2288:31",
33+
"axonflow/client.py:2300:25",
34+
"axonflow/client.py:2361:28",
35+
"axonflow/client.py:2402:69",
36+
"axonflow/client.py:292:14",
37+
"axonflow/client.py:297:24",
38+
"axonflow/client.py:298:20",
39+
"axonflow/client.py:523:44",
40+
"axonflow/client.py:6206:25",
41+
"axonflow/client.py:834:20",
42+
"axonflow/client.py:920:20",
4343
"axonflow/execution.py:205:19",
4444
"axonflow/interceptors/anthropic.py:134:43",
4545
"axonflow/interceptors/anthropic.py:161:43",

axonflow/client.py

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@
7878
ValidateGitProviderRequest,
7979
ValidateGitProviderResponse,
8080
)
81-
from axonflow.decisions import DecisionExplanation
81+
from axonflow.decisions import (
82+
DecisionExplanation,
83+
DecisionSummary,
84+
ListDecisionsOptions,
85+
)
8286
from axonflow.exceptions import (
8387
AuthenticationError,
8488
AxonFlowError,
@@ -88,7 +92,9 @@
8892
IdempotencyKeyMismatchError,
8993
PlanExecutionError,
9094
PolicyViolationError,
95+
RateLimitError,
9196
TimeoutError,
97+
UpgradeInfo,
9298
VersionConflictError,
9399
)
94100
from axonflow.execution import (
@@ -385,6 +391,35 @@ def _parse_pending_approvals_response(response: dict[str, Any]) -> PendingApprov
385391
)
386392

387393

394+
def _build_list_decisions_query(opts: ListDecisionsOptions | None) -> str:
395+
"""Serialize :class:`ListDecisionsOptions` into the URL query string.
396+
397+
``None`` values are omitted so the platform applies its tier-default
398+
page. Stable field order so test mocks can match the URL exactly.
399+
"""
400+
if opts is None:
401+
return ""
402+
pairs: list[tuple[str, str]] = []
403+
if opts.since is not None:
404+
# Use the "Z" suffix for UTC; isoformat() emits +00:00 which
405+
# would urlencode to %2B00%3A00 and over-noise the URL. The
406+
# platform parses both, but the Z form matches the explain
407+
# endpoint's response shape byte-for-byte.
408+
ts = opts.since
409+
if ts.utcoffset() is not None:
410+
ts = ts.astimezone(tz=ts.tzinfo).replace(tzinfo=None)
411+
pairs.append(("since", ts.strftime("%Y-%m-%dT%H:%M:%SZ")))
412+
if opts.decision is not None:
413+
pairs.append(("decision", opts.decision))
414+
if opts.policy_id is not None:
415+
pairs.append(("policy_id", opts.policy_id))
416+
if opts.tool_signature is not None:
417+
pairs.append(("tool_signature", opts.tool_signature))
418+
if opts.limit is not None:
419+
pairs.append(("limit", str(opts.limit)))
420+
return urlencode(pairs)
421+
422+
388423
def _build_audit_search_body(request: AuditSearchRequest) -> dict[str, Any]:
389424
"""Build the POST /api/v1/audit/search body from an AuditSearchRequest.
390425
@@ -2737,6 +2772,103 @@ async def explain_decision(self, decision_id: str) -> DecisionExplanation:
27372772
response = {}
27382773
return DecisionExplanation.model_validate(response)
27392774

2775+
async def list_decisions(
2776+
self,
2777+
opts: ListDecisionsOptions | None = None,
2778+
) -> list[DecisionSummary]:
2779+
"""List recent policy decisions for the caller's tenant.
2780+
2781+
Implements ``GET /api/v1/decisions``. Returns the slim 5-field
2782+
:class:`DecisionSummary` page; the platform applies a tier-gated
2783+
cap (5/24h Free + Community, 100/30d Pro + Evaluation, 1000/full
2784+
retention Enterprise). Over-cap requests yield a 429 with the V1
2785+
upgrade envelope, surfaced as
2786+
:class:`axonflow.exceptions.RateLimitError` carrying the parsed
2787+
``upgrade.{tier,compare_url,buy_url}`` so the caller can branch on
2788+
them without re-parsing the body.
2789+
2790+
Args:
2791+
opts: Filter + page-size options. ``None`` returns the
2792+
tier-default page from the caller's tenant.
2793+
2794+
Returns:
2795+
A list of DecisionSummary rows ordered newest-first.
2796+
2797+
Raises:
2798+
RateLimitError: 429 tier-cap; ``rle.upgrade`` exposes
2799+
tier/compare_url/buy_url.
2800+
AxonFlowError: Other HTTP errors (401, 5xx, etc.).
2801+
2802+
Example:
2803+
>>> from axonflow.decisions import ListDecisionsOptions
2804+
>>> opts = ListDecisionsOptions(decision="deny", limit=10)
2805+
>>> for d in await client.list_decisions(opts):
2806+
... print(d.decision_id, d.decision, d.timestamp)
2807+
"""
2808+
path = "/api/v1/decisions"
2809+
qs = _build_list_decisions_query(opts)
2810+
if qs:
2811+
path = f"{path}?{qs}"
2812+
2813+
# Hand-roll the request so we can branch on 429 BEFORE
2814+
# raise_for_status promotes it to a generic AxonFlowError.
2815+
# Other failure modes fall through to the same shape
2816+
# _orchestrator_request would have produced.
2817+
self._pre_request_hook()
2818+
url = f"{self._config.endpoint}{path}"
2819+
try:
2820+
response = await self._http_client.get(url)
2821+
except httpx.ConnectError as e:
2822+
msg = f"Failed to connect to Orchestrator: {e}"
2823+
raise ConnectionError(msg) from e
2824+
except httpx.TimeoutException as e:
2825+
msg = f"Request timed out: {e}"
2826+
raise TimeoutError(msg) from e
2827+
2828+
if response.status_code == 429: # noqa: PLR2004
2829+
# Try to parse the V1 envelope. Malformed body falls back to
2830+
# a plain RateLimitError without upgrade context — never
2831+
# silently succeed.
2832+
try:
2833+
envelope = response.json()
2834+
except (ValueError, json.JSONDecodeError):
2835+
envelope = {}
2836+
limit_type = envelope.get("limit_type")
2837+
upgrade_dict = envelope.get("upgrade")
2838+
upgrade = None
2839+
if isinstance(upgrade_dict, dict):
2840+
upgrade = UpgradeInfo(
2841+
tier=upgrade_dict.get("tier", ""),
2842+
wording=upgrade_dict.get("wording", ""),
2843+
compare_url=upgrade_dict.get("compare_url", ""),
2844+
buy_url=upgrade_dict.get("buy_url", ""),
2845+
)
2846+
error_msg = envelope.get("error")
2847+
if not isinstance(error_msg, str) or error_msg == "":
2848+
error_msg = "rate limit exceeded"
2849+
raw_limit = envelope.get("limit")
2850+
raw_remaining = envelope.get("remaining")
2851+
raise RateLimitError(
2852+
error_msg,
2853+
limit=int(raw_limit) if isinstance(raw_limit, (int, float)) else 0,
2854+
remaining=(int(raw_remaining) if isinstance(raw_remaining, (int, float)) else 0),
2855+
limit_type=limit_type,
2856+
tier=envelope.get("tier"),
2857+
upgrade=upgrade,
2858+
)
2859+
2860+
if response.status_code >= 400: # noqa: PLR2004
2861+
msg = f"HTTP {response.status_code}: {response.text}"
2862+
raise AxonFlowError(msg)
2863+
2864+
body = response.json()
2865+
if not isinstance(body, dict):
2866+
return []
2867+
rows = body.get("decisions")
2868+
if not isinstance(rows, list):
2869+
return []
2870+
return [DecisionSummary.model_validate(r) for r in rows]
2871+
27402872
async def get_audit_logs_by_tenant(
27412873
self,
27422874
tenant_id: str,

axonflow/decisions.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
Implements ADR-043 (Explainability Data Contract). The DecisionExplanation
44
shape is frozen; additive-only changes are allowed; renames/removals require
55
a major version bump.
6+
7+
list_decisions companion (list_decisions / #1982) reuses the same conventions:
8+
DecisionSummary is the slim 5-field row; ListDecisionsOptions carries the
9+
5 optional filters.
610
"""
711

812
from __future__ import annotations
913

1014
from datetime import datetime
1115

12-
from pydantic import BaseModel, Field
16+
from pydantic import BaseModel, ConfigDict, Field
1317

1418

1519
class ExplainPolicy(BaseModel):
@@ -69,3 +73,46 @@ class DecisionExplanation(BaseModel):
6973
historical_hit_count_session: int = 0
7074
policy_source_link: str | None = None
7175
tool_signature: str | None = None
76+
77+
78+
class DecisionSummary(BaseModel):
79+
"""Slim 5-field row returned by ``client.list_decisions``.
80+
81+
``policy_id`` and ``tool_signature`` are optional because dynamic-only
82+
blocks + pre-V1.1 audit rows may not populate them. Additive new fields
83+
are non-breaking per ADR-043 §"Versioning"; arbitrary unknown fields
84+
on the wire are accepted via ``extra='ignore'``.
85+
86+
Cross-SDK parity:
87+
88+
Go: axonflow-sdk-go/decisions.go (DecisionSummary)
89+
TS: axonflow-sdk-typescript/src/types/decisions.ts (DecisionSummary)
90+
Java: .../sdk/types/DecisionSummary.java
91+
Rust: axonflow-sdk-rust/src/types/decisions.rs (DecisionSummary)
92+
"""
93+
94+
model_config = ConfigDict(extra="ignore")
95+
96+
decision_id: str
97+
timestamp: datetime
98+
decision: str # allow | deny | require_approval
99+
policy_id: str | None = None
100+
tool_signature: str | None = None
101+
102+
103+
class ListDecisionsOptions(BaseModel):
104+
"""Optional filters for ``client.list_decisions``.
105+
106+
Every field is optional; ``None`` values are omitted from the URL so
107+
the platform applies its tier-default page. ``decision`` must be one
108+
of ``"allow"``, ``"deny"``, or ``"require_approval"`` when set.
109+
``limit`` is server-capped per tier; over-cap requests yield a 429
110+
with the V1 upgrade envelope (surfaced as
111+
:class:`axonflow.exceptions.RateLimitError`).
112+
"""
113+
114+
since: datetime | None = None
115+
decision: str | None = None
116+
policy_id: str | None = None
117+
tool_signature: str | None = None
118+
limit: int | None = None

axonflow/exceptions.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,23 +42,70 @@ def __init__(
4242
self.block_reason = block_reason
4343

4444

45+
class UpgradeInfo:
46+
"""Pricing-tier upgrade context emitted in a V1 429 envelope.
47+
48+
Mirrors the platform-side
49+
feedback_429_no_upgrade_hint_is_conversion_gap.md contract.
50+
Cross-SDK parity:
51+
52+
Go: axonflow-sdk-go/decisions.go (UpgradeInfo)
53+
TS: axonflow-sdk-typescript/src/types/decisions.ts (UpgradeInfo)
54+
Java: .../sdk/types/UpgradeInfo.java
55+
Rust: axonflow-sdk-rust/src/types/decisions.rs (UpgradeInfo)
56+
"""
57+
58+
__slots__ = ("buy_url", "compare_url", "tier", "wording")
59+
60+
def __init__(
61+
self,
62+
tier: str,
63+
wording: str,
64+
compare_url: str,
65+
buy_url: str,
66+
) -> None:
67+
self.tier = tier
68+
self.wording = wording
69+
self.compare_url = compare_url
70+
self.buy_url = buy_url
71+
72+
4573
class RateLimitError(AxonFlowError):
46-
"""Rate limit exceeded."""
74+
"""Rate limit exceeded.
75+
76+
Holds the parsed V1 429 envelope when available. ``limit_type`` and
77+
``tier`` and ``upgrade`` are populated for tier-cap 429s (e.g.
78+
``list_decisions`` page-size cap, daily-quota cap); legacy 429s
79+
leave them ``None`` for backwards compatibility.
80+
"""
4781

4882
def __init__(
4983
self,
5084
message: str,
5185
limit: int,
5286
remaining: int,
5387
reset_at: str | None = None,
88+
*,
89+
limit_type: str | None = None,
90+
tier: str | None = None,
91+
upgrade: UpgradeInfo | None = None,
5492
) -> None:
5593
super().__init__(
5694
message,
57-
details={"limit": limit, "remaining": remaining, "reset_at": reset_at},
95+
details={
96+
"limit": limit,
97+
"remaining": remaining,
98+
"reset_at": reset_at,
99+
"limit_type": limit_type,
100+
"tier": tier,
101+
},
58102
)
59103
self.limit = limit
60104
self.remaining = remaining
61105
self.reset_at = reset_at
106+
self.limit_type = limit_type
107+
self.tier = tier
108+
self.upgrade = upgrade
62109

63110

64111
class BudgetExceededError(AxonFlowError):

0 commit comments

Comments
 (0)