Decisions API
Endpoints for retrieving policy-decision context. Implements the platform's explainability data contract (ADR-043) and its 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. |
Not to be confused with the Decision Mode PDP endpoint POST /api/v1/decide, which is served by the Agent and evaluates a new request to a verdict — see Decision Mode. This page covers reading back decisions that were already made.
Both endpoints are served by the Orchestrator and proxied through the Agent gateway on 8080. Callers authenticate at the Agent with Basic auth (CLIENT_ID:CLIENT_SECRET); the gateway sets the trusted X-Tenant-ID header from the authenticated credential (any client-supplied value is overwritten). Callers hitting the Orchestrator directly must set X-Tenant-ID themselves — both handlers return 401 without it, and tenant isolation is enforced 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 v7.9.0+.
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 at the Agent gateway (Basic auth); the gateway forwards the request with the trusted
X-Tenant-IDheader set. X-Tenant-IDis required — a missing header returns401. The tenant identifier is enforced at the SQLWHERElevel, not as a post-fetch comparison — there is no enumeration oracle, and the listing can never return another tenant's rows.
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 the canonical verdicts: allowed, blocked, redacted, needs_approval, error. Any other value (including legacy spellings such as allow, deny, require_approval) returns 400. The filter also matches historical rows stored under legacy spellings of the same verdict. |
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. Must be a positive integer. Defaults to the tier maximum; a value above the tier maximum returns 429 — see Tier-gated window and page size. |
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": "blocked",
"policy_id": "sys_sqli_drop_table",
"tool_signature": "postgres.query",
"context": {
"session_id": "sess-9f2",
"ai_agent": "claude-code"
}
},
{
"decision_id": "dec_wf123_step3",
"timestamp": "2026-05-07T12:16:31Z",
"decision": "allowed",
"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 | Canonical verdict: allowed | blocked | redacted | needs_approval | error. Historical rows stored under legacy spellings are normalized to the canonical value on read, so the feed never shows a divergent verdict. |
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. |
context | object | no | Sanitized request context the enforcement point attached to the decision (snake_case string keys/values). The list summary is truncated to the first 5 keys (sorted); the full map is on /explain. Omitted when the decision carries no context. |
transfer_basis | string | no | Cross-border transfer basis auto-stamped on LLM-forward decision rows. Omitted for non-cross-border decisions. |
data_residency | string | no | Data-residency marker paired with transfer_basis. Omitted when not applicable. |
The summary intentionally omits reason, risk_level, policy_matches, matched_rules, override_available, and the 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 | last 30 days | 100 |
| SaaS Premium | 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.
On SaaS deployments the Agent gateway resolves the per-credential tier and forwards it to the Orchestrator in the X-Axonflow-Effective-Tier header (any inbound value of that header is stripped and replaced at the gateway). Self-hosted deployments fall back to the deployment-wide license tier.
Error responses
| Status | Meaning |
|---|---|
| 400 | Malformed since timestamp, non-positive limit, or a decision value outside the canonical verdict set. |
| 401 | Missing X-Tenant-ID (the Agent gateway sets it automatically after authentication; bare-Orchestrator callers must include it). |
| 429 | Requested limit exceeds the tier's page-size cap — see Hitting the page-size cap. |
| 500 | Internal query failure. |
Hitting the page-size cap
When a caller explicitly requests a limit above the tier maximum (for example, a Free-tier or Community-tier caller requesting limit > 5) the response is 429 with the standard 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. Requests without an explicit limit never hit this cap — they default to the tier maximum and are simply truncated to it.
{
"error": "Free tier shows the last 5 decisions in 24h. Pro raises this to 100 decisions in the last 30 days.",
"limit_type": "decision_list_size",
"tier": "Free",
"limit": 5,
"remaining": 0,
"upgrade": {
"tier": "Pro",
"wording": "Free tier shows the last 5 decisions in 24h. Pro raises this to 100 decisions in the last 30 days.",
"compare_url": "https://getaxonflow.com/pricing/",
"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 | The limit type that was hit. |
X-Axonflow-Upgrade-URL | https://getaxonflow.com/pricing/ | Tier 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 blocked-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=blocked&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=blocked&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
X-Tenant-IDis required (401without it) and the lookup is tenant-scoped in the SQLWHEREclause — a decision belonging to another tenant is indistinguishable from a nonexistent one (404, no enumeration oracle).- Any caller within the same tenant can explain a decision (peer visibility is the current contract). Caller identity from
X-User-Email(orX-User-ID) scopes thehistorical_hit_count_sessionandoverride_existing_idfields to that user. - 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": "blocked",
"reason": "SQL injection patterns detected",
"risk_level": "high",
"policy_matches": [
{
"policy_id": "pol-sqli-detector",
"policy_name": "SQL Injection Detector",
"action": "block",
"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 | The verdict as stored on the audit row — canonical values are allowed | blocked | redacted | needs_approval | error. Unlike the list endpoint, explain returns the stored value as-is, so rows written before the canonical-vocabulary cutover may surface legacy spellings (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 session ("allow") override for this caller/policy/tool scope. Action-replacement overrides are not reported here. |
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. |
context | object | no | The full sanitized request context the enforcement point attached to the decision (snake_case string keys/values, up to the 10-key cap applied at write time). The list endpoint truncates this to 5 keys; explain returns every persisted key. Omitted for decisions with no context. |
context_truncated | bool | no | True when the write path dropped surplus context keys beyond the persistence cap. Omitted when false. |
policy_version_at_decision | int | no | Version of the matched policy that fired this decision (scoped to the first matched policy). Recorded by the audit-write path at decision time and read back from audit_logs.policy_details. Omitted for decisions made before version recording shipped (platform v7.9.0) and for dynamic-policy decisions (no version concept). |
latest_policy_version | int | no | 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?"
There is no dedicated rule-level diff endpoint; compose the existing /static-policies/:id/versions endpoint with the two version numbers to see what changed.
Error responses
| Status | Meaning |
|---|---|
| 400 | decision_id missing (blank path segment) |
| 401 | Missing X-Tenant-ID header |
| 404 | Decision not found within the caller's tenant, or past the tier retention window (also returned for decisions belonging to other tenants — no enumeration oracle) |
| 500 | Internal lookup failure |
Example
curl -X GET https://your-platform/api/v1/decisions/dec_wf123_step4/explain \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "X-Tenant-ID: $TENANT_ID"
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 policy_version_at_decision, latest_policy_version, context, and context_truncated fields 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
