Skip to main content

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:

EndpointReturnsUse when
GET /api/v1/decisionsSlim summary of recent decisionsBuilding a "what just got blocked?" feed, dashboard, or alert.
GET /api/v1/decisions/{decision_id}/explainFull structured DecisionExplanationAfter 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-ID header set.
  • X-Tenant-ID is required — a missing header returns 401. The tenant identifier is enforced at the SQL WHERE level, 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.

NameTypeDescription
sinceRFC3339 timestampLower 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.
decisionstringExact-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_idstringExact-match a single policy_id within the decision's matched-policy set.
tool_signaturestringExact-match the tool the decision was scoped to (e.g. postgres.query, slack.send).
limitintegerMaximum 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:

FieldTypeRequiredDescription
decision_idstringyesGlobal decision identifier. Pass to /explain for full detail.
timestampISO 8601 stringyesWhen the decision was made.
decisionstringyesCanonical 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_idstringnoPrimary matched policy. Omitted for default-allow decisions where no policy matched.
tool_signaturestringnoScoped tool, if the decision was tool-scoped.
contextobjectnoSanitized 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_basisstringnoCross-border transfer basis auto-stamped on LLM-forward decision rows. Omitted for non-cross-border decisions.
data_residencystringnoData-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.

TierLookback windowMax page size
SaaS Freelast 24 hours5
SaaS Prolast 30 days100
SaaS Premiumlast 30 days100
Self-host Communitylast 24 hours5
Self-host Evaluationlast 14 days100
Self-host Enterprisefull audit retention1000

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

StatusMeaning
400Malformed since timestamp, non-positive limit, or a decision value outside the canonical verdict set.
401Missing X-Tenant-ID (the Agent gateway sets it automatically after authentication; bare-Orchestrator callers must include it).
429Requested limit exceeds the tier's page-size cap — see Hitting the page-size cap.
500Internal 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:

HeaderExample valueMeaning
X-Axonflow-Tier-Limitdecision_list_sizeThe limit type that was hit.
X-Axonflow-Upgrade-URLhttps://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-ID is required (401 without it) and the lookup is tenant-scoped in the SQL WHERE clause — 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 (or X-User-ID) scopes the historical_hit_count_session and override_existing_id fields to that user.
  • Bounded by tier retention (see explainability).

Parameters

NameLocationTypeDescription
decision_idpathstringGlobal 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:

FieldTypeRequiredDescription
decision_idstringyesEchoes the path param.
timestampISO 8601 stringyesWhen the decision was made.
decisionstringyesThe 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).
reasonstringyesHuman-readable reason. May be empty for allow decisions.
risk_levelstringnolow | medium | high | critical.
policy_matchesarrayyesPolicies that contributed to the decision. Can be empty for default-allow.
matched_rulesarraynoRule-level detail. Populated when the upstream engine supports it.
override_availableboolyesTrue iff at least one matched policy has allow_override=true AND risk_level != critical.
override_existing_idstringnoID of an already-active session ("allow") override for this caller/policy/tool scope. Action-replacement overrides are not reported here.
historical_hit_count_sessionintyesTimes the same (policy, user) pair hit in the rolling 24h window.
policy_source_linkstringnoURL to the policy definition.
tool_signaturestringnoScoped tool name, if the decision was tool-scoped.
contextobjectnoThe 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_truncatedboolnoTrue when the write path dropped surplus context keys beyond the persistence cap. Omitted when false.
policy_version_at_decisionintnoVersion 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_versionintnoCurrent 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:

  1. The user's recent run got blocked. Operator pulls /explain for the decision_id.
  2. Operator reads policy_version_at_decision = 3 and latest_policy_version = 5.
  3. 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.
  4. Operator pulls /api/v1/static-policies/{policy_id}/versions to 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

StatusMeaning
400decision_id missing (blank path segment)
401Missing X-Tenant-ID header
404Decision not found within the caller's tenant, or past the tier retention window (also returned for decisions belonging to other tenants — no enumeration oracle)
500Internal 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 APIGET .../{id}/versions to read the diff between policy_version_at_decision and latest_policy_version
  • Audit APIdecision_id, policy_name, and override_id filters 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: