Skip to main content

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:

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.

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-ID header is required. The tenant identifier is enforced at the SQL WHERE level, 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.

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 allow, deny, require_approval.
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. 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:

FieldTypeRequiredDescription
decision_idstringyesGlobal decision identifier. Pass to /explain for full detail.
timestampISO 8601 stringyesWhen the decision was made.
decisionstringyesallow | deny | require_approval.
policy_idstringnoPrimary matched policy. Omitted for default-allow decisions where no policy matched.
tool_signaturestringnoScoped 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.

TierLookback windowMax page size
SaaS Freelast 24 hours5
SaaS Pro ($9.99 / 90 days)last 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.

Error responses

StatusMeaning
400Malformed since timestamp or unknown filter param value (decision not in the closed set, etc.).
401Auth failure or missing X-Tenant-ID.
403Authenticated caller does not own the requested tenant.
429Tier 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:

HeaderExample valueMeaning
X-Axonflow-Tier-Limitdecision_list_size=5Limit type and tier max.
X-Axonflow-Upgrade-Urlhttps://getaxonflow.com/proPro 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

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": "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:

FieldTypeRequiredDescription
decision_idstringyesEchoes the path param.
timestampISO 8601 stringyesWhen the decision was made.
decisionstringyesallow | 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 override for this caller/policy.
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.
policy_version_at_decisionintnoV1.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_versionintnoV1.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:

  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?"

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

StatusMeaning
400decision_id missing or malformed
401Auth failure
403Caller does not own the decision and is not in the same tenant
404Decision 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 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: