Decisions API
Endpoints for retrieving policy-decision context. Implements the contract in ADR-043 and the V1.1 amendments for policy_version_at_decision plus a tier-gated listing surface.
The Decisions API has two endpoints that compose:
| Endpoint | Returns | Use when |
|---|---|---|
GET /api/v1/decisions | Slim summary of recent decisions | Building a "what just got blocked?" feed, dashboard, or alert. |
GET /api/v1/decisions/{decision_id}/explain | Full structured DecisionExplanation | After a block, to understand or appeal a specific decision. |
Both endpoints require Basic auth (CLIENT_ID:CLIENT_SECRET) or a valid JWT, and enforce tenant isolation at the SQL WHERE level (no enumeration oracle). See Auth header matrix.
For the conceptual framing of the decision-oriented execution record, see Decision Record. For the data contract behind DecisionExplanation, see Explainability.
Availability. GET .../{decision_id}/explain is available on platform v7.1.0+. The listing endpoint and the policy_version_at_decision / latest_policy_version fields require platform v1.1.
GET /api/v1/decisions
List recent policy decisions for the authenticated tenant. Returns a slim summary shape suitable for feeds and dashboards; call the explain endpoint with any returned decision_id to get the full structured detail.
Authorization
- Caller must authenticate (Basic auth or JWT).
X-Tenant-IDheader is required. The tenant identifier is enforced at the SQLWHERElevel, not as a post-fetch comparison — there is no enumeration oracle.- Cross-tenant listing returns
403.
Parameters
All filters are query-string parameters. All are optional except where noted.
| Name | Type | Description |
|---|---|---|
since | RFC3339 timestamp | Lower bound for timestamp. Defaults to the start of your tier's listing window (see below). Values older than the tier window are clamped silently to the window start — no error is returned. |
decision | string | Exact-match decision outcome. One of allow, deny, require_approval. |
policy_id | string | Exact-match a single policy_id within the decision's matched-policy set. |
tool_signature | string | Exact-match the tool the decision was scoped to (e.g. postgres.query, slack.send). |
limit | integer | Maximum decisions to return. Capped per tier — see Tier-gated window and page size. Defaults to the tier maximum. |
Response (200)
The response body is a JSON object with a single decisions field — an array of DecisionSummary objects ordered newest-first.
{
"decisions": [
{
"decision_id": "dec_wf123_step4",
"timestamp": "2026-05-07T12:16:36Z",
"decision": "deny",
"policy_id": "sys_sqli_drop_table",
"tool_signature": "postgres.query"
},
{
"decision_id": "dec_wf123_step3",
"timestamp": "2026-05-07T12:16:31Z",
"decision": "allow",
"tool_signature": "postgres.query"
}
]
}
DecisionSummary fields:
| Field | Type | Required | Description |
|---|---|---|---|
decision_id | string | yes | Global decision identifier. Pass to /explain for full detail. |
timestamp | ISO 8601 string | yes | When the decision was made. |
decision | string | yes | allow | deny | require_approval. |
policy_id | string | no | Primary matched policy. Omitted for default-allow decisions where no policy matched. |
tool_signature | string | no | Scoped tool, if the decision was tool-scoped. |
The summary intentionally omits reason, risk_level, policy_matches, matched_rules, override_available, and the V1.1 policy-version fields. Call /explain with any decision_id to get the full payload.
Tier-gated window and page size
The listing surface is intentionally tiered. Free users see the most recent block; paid tiers get a window long enough to actually browse the decision record over time. The window is bounded by audit retention — listings cannot return decisions older than the underlying audit row.
| Tier | Lookback window | Max page size |
|---|---|---|
| SaaS Free | last 24 hours | 5 |
| SaaS Pro ($9.99 / 90 days) | last 30 days | 100 |
| Self-host Community | last 24 hours | 5 |
| Self-host Evaluation | last 14 days | 100 |
| Self-host Enterprise | full audit retention | 1000 |
Pro's "last 30 days" matches Pro's 30-day audit retention exactly. Free's "last 24 hours" is intentionally shorter than the Free 3-day retention — the Pro upgrade is what unlocks the full retention window in the listing surface.
Error responses
| Status | Meaning |
|---|---|
| 400 | Malformed since timestamp or unknown filter param value (decision not in the closed set, etc.). |
| 401 | Auth failure or missing X-Tenant-ID. |
| 403 | Authenticated caller does not own the requested tenant. |
| 429 | Tier listing cap exceeded — see Hitting the page-size cap. |
Hitting the page-size cap
When a Free-tier or Community-tier caller requests limit > 5 (or has more than 5 matching decisions in the 24-hour window) the response is 429 with the standard V1 upgrade envelope, identical in shape to the rate-limit envelope returned by the daily-quota cap. A plugin or host CLI can use a single parse path for both.
{
"error": "decision list page limit reached for your tier",
"limit_type": "decision_list_size",
"tier": "Free",
"limit": 5,
"remaining": 0,
"upgrade": {
"tier": "Pro",
"wording": "Free returns the most-recent 5 decisions in the last 24h. Pro returns 100 across 30 days.",
"compare_url": "https://getaxonflow.com/pro",
"buy_url": "https://buy.stripe.com/bJe28qbztcdVchjdkw8k800"
}
}
The 429 response also carries upgrade context in response headers so non-JSON clients (curl scripts, shell pipelines, plugin hooks that don't parse the body) can surface the prompt without parsing JSON:
| Header | Example value | Meaning |
|---|---|---|
X-Axonflow-Tier-Limit | decision_list_size=5 | Limit type and tier max. |
X-Axonflow-Upgrade-Url | https://getaxonflow.com/pro | Pro comparison URL. |
Plugins and host CLIs surface this as a clear "upgrade to Pro to see more" prompt rather than silently truncating. The body wording is the source of truth — headers are a discoverability convenience.
Examples
List the 20 most recent decisions, any outcome, within your tier's window:
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-H "X-Tenant-ID: $TENANT_ID" \
"https://your-platform/api/v1/decisions?limit=20"
Filter to deny-only decisions for one tool over the last 6 hours:
SINCE=$(date -u -v-6H +"%Y-%m-%dT%H:%M:%SZ") # macOS BSD date
# Linux: SINCE=$(date -u -d '6 hours ago' +"%Y-%m-%dT%H:%M:%SZ")
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-H "X-Tenant-ID: $TENANT_ID" \
"https://your-platform/api/v1/decisions?since=$SINCE&decision=deny&tool_signature=postgres.query"
Compose list → explain to fetch the full payload of the most-recent block:
DECISION_ID=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-H "X-Tenant-ID: $TENANT_ID" \
"https://your-platform/api/v1/decisions?decision=deny&limit=1" \
| jq -r '.decisions[0].decision_id')
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-H "X-Tenant-ID: $TENANT_ID" \
"https://your-platform/api/v1/decisions/$DECISION_ID/explain" | jq .
Versioning
The DecisionSummary shape may grow additional omitempty fields in future minor versions (per ADR-043 §"Versioning"). Field renames or removals require a major version bump. Clients should tolerate unknown fields.
GET /api/v1/decisions/{decision_id}/explain
Fetch the full explanation for a previously-made policy decision.
Authorization
- Caller must either own the decision (user_email match) or belong to the same tenant.
- Bounded by tier retention (see explainability).
Parameters
| Name | Location | Type | Description |
|---|---|---|---|
decision_id | path | string | Global decision identifier returned in the original step gate / policy evaluation response. URL-encoded. |
Response (200)
{
"decision_id": "dec_wf123_step4",
"timestamp": "2026-04-17T12:00:00Z",
"decision": "deny",
"reason": "SQL injection patterns detected",
"risk_level": "high",
"policy_matches": [
{
"policy_id": "pol-sqli-detector",
"policy_name": "SQL Injection Detector",
"action": "deny",
"risk_level": "high",
"allow_override": true,
"policy_description": "Blocks SQL injection patterns"
}
],
"matched_rules": [
{
"policy_id": "pol-sqli-detector",
"rule_id": "sqli-union-select",
"rule_text": "Contains UNION SELECT keyword combination",
"matched_on": "query.sql"
}
],
"override_available": true,
"override_existing_id": "ov-f3a81c...",
"historical_hit_count_session": 3,
"policy_source_link": "https://policies.axonflow/sqli-detector",
"tool_signature": "Bash",
"policy_version_at_decision": 3,
"latest_policy_version": 5
}
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
decision_id | string | yes | Echoes the path param. |
timestamp | ISO 8601 string | yes | When the decision was made. |
decision | string | yes | allow | deny | require_approval. |
reason | string | yes | Human-readable reason. May be empty for allow decisions. |
risk_level | string | no | low | medium | high | critical. |
policy_matches | array | yes | Policies that contributed to the decision. Can be empty for default-allow. |
matched_rules | array | no | Rule-level detail. Populated when the upstream engine supports it. |
override_available | bool | yes | True iff at least one matched policy has allow_override=true AND risk_level != critical. |
override_existing_id | string | no | ID of an already-active override for this caller/policy. |
historical_hit_count_session | int | yes | Times the same (policy, user) pair hit in the rolling 24h window. |
policy_source_link | string | no | URL to the policy definition. |
tool_signature | string | no | Scoped tool name, if the decision was tool-scoped. |
policy_version_at_decision | int | no | V1.1. Version of the matched policy that fired this decision. Recorded by the audit-write path at decision time and read back from audit_logs.policy_details. Omitted for decisions made before V1.1 (no version was recorded) and for dynamic-policy decisions (no version concept). |
latest_policy_version | int | no | V1.1. Current head of static_policy_versions for the same policy_id, looked up at explain time. Omitted when policy_version_at_decision is omitted, or when the policy_id no longer resolves (e.g. policy was deleted). |
Forensic workflow — answering "why is this blocked NOW that wasn't 2 days ago?"
Together, policy_version_at_decision and latest_policy_version answer the most common decision-archaeology question without an extra diff endpoint:
- The user's recent run got blocked. Operator pulls
/explainfor thedecision_id. - Operator reads
policy_version_at_decision = 3andlatest_policy_version = 5. - Operator now knows the policy has changed twice since the user's last successful run. The block isn't a runtime regression — it's a policy update.
- Operator pulls
/api/v1/static-policies/{policy_id}/versionsto see what changed between v3 and v5, and decides whether to roll the policy back, grant a Session Override, or escalate.
When the two integers are equal, the policy hasn't changed since the decision — the block is "the same rule that's been there all along," and the question shifts from "what changed?" to "why is the user trying this now?"
The rule-level diff endpoint that returns a structured diff between two policy versions for the same decision_id is on the V1.2 roadmap (GET /api/v1/decisions/:id/policy-version-diff). Until then, compose the existing /static-policies/:id/versions endpoint with two version numbers.
Error responses
| Status | Meaning |
|---|---|
| 400 | decision_id missing or malformed |
| 401 | Auth failure |
| 403 | Caller does not own the decision and is not in the same tenant |
| 404 | Decision not found or past tier retention window |
Example
curl -X GET https://your-platform/api/v1/decisions/dec_wf123_step4/explain \
-u "$CLIENT_ID:$CLIENT_SECRET"
Versioning
The response shape is frozen per ADR-043. Additive fields may appear in future minor versions with omitempty semantics — clients should tolerate unknown fields. Renames or removals require a major version bump. The V1.1 fields policy_version_at_decision and latest_policy_version are additive per this rule.
See also
- Decision Record — conceptual framing of the decision-oriented execution record (list + explain composed)
- Explainability — the data contract behind
DecisionExplanation - Session Overrides — the next step when
override_available: true - Static Policies API —
GET .../{id}/versionsto read the diff betweenpolicy_version_at_decisionandlatest_policy_version - Audit API —
decision_id,policy_name, andoverride_idfilters for full-history queries beyond the listing window
Operational Readiness Checklist
Before relying on this page in a production rollout, pair it with the core operations docs:
- Deployment Mode Matrix for self-hosted, Evaluation, Enterprise, SaaS, and In-VPC fit
- Failure Modes And Recovery for degraded-provider, connector, approval, and runtime behavior
- Capacity Planning for sizing and growth signals
- Community vs Evaluation vs Enterprise for limits, support surfaces, and upgrade triggers
