Skip to main content
Earlier release

This guide covers AxonFlow v8.x → v9.0.0. The current release is v11.1.0 - see the release notes for what has shipped since, and Deployment for a deployment starting today. The next guide in the chain is v9 → v10 Migration Guide.

It is kept because readers upgrading from an earlier version need it, and its version numbers are the versions it migrates between - they are correct and are not swept by the version-pin guard.

v8 → v9 Migration Guide

AxonFlow's v9.0.0 change is an audit-vocabulary and observability cleanup, not a breaking redesign of the enforcement API. For customers using the SDKs, plugins, or HTTP API to enforce decisions, no application changes are required: the wire verdict your Policy Enforcement Point (PEP) reads — allow / deny / needs_approval — is unchanged and will never change value.

What v9 changes is the persisted decision label and the read/reporting surfaces built on top of it: every audit row, the GET /api/v1/decisions feed and its filter, the regulator exports (SEBI, EU AI Act), and the customer-portal audit view now converge on a single canonical decision vocabulary. Before v9, four surfaces each spelled the same decision differently (allow vs allowed, deny vs blocked, a redaction shown as modified), which produced mislabeled and unfilterable decisions in the audit feed.

One-line summary: keep enforcing on the unchanged allow / deny / needs_approval wire verdict; the audit decision label and the /api/v1/decisions read API now use one canonical set — allowed | blocked | redacted | needs_approval | error.

The most important distinction: wire verdict vs. audit label​

These are two different things, and only the audit label changed:

Wire / PEP verdictAudit label (policy_decision)
Where you see itPOST /api/v1/decide response decision fieldaudit_logs.policy_decision column; GET /api/v1/decisions; regulator exports; portal audit view
Valuesallow | deny | needs_approvalallowed | blocked | redacted | needs_approval | error
PurposeThe instruction your PEP enforces in real timeThe persisted, reportable record of what happened
Changed in v9?No — frozen caller contractYes — canonicalized

If your integration only enforces decisions (the common case — SDK decide / check-input / check-output calls, plugins, the LLM/MCP/agent gateways), it reads the real-time enforcement signal and is unaffected by v9. If your integration reads or reports decisions (the GET /api/v1/decisions feed, a compliance export, a dashboard that string-matches the audit label), read on.

What changed — the canonical policy_decision vocabulary​

Every decision AxonFlow persists is now labeled with exactly one of five canonical verdicts (plus one non-verdict lifecycle marker):

Canonical valueMeaning
allowedThe request/response was permitted unchanged. Also the truthful label for detect-but-don't-modify outcomes (warn/log): a detector fired, nothing was blocked or altered.
blockedThe request/response was denied; nothing reached the downstream model/tool, or the response was withheld.
redactedThe request/response was permitted but modified (PII masked, fields stripped). Distinct from allowed so a redaction is never mislabeled as a clean allow.
needs_approvalThe decision is deferred to a human (HITL); neither allowed nor blocked yet.
errorThe decision could not be computed (engine error, failed plan/tool step). Also the fail-safe label for any unrecognized value — an unclassifiable verdict is never silently treated as allowed.
override_lifecycleA non-verdict marker for policy-override grant/revoke events. Not a decision; excluded from verdict-centric feeds.

Old → new value map​

If you have stored decision labels, dashboards, or code that string-matches the audit label, this is the mapping AxonFlow now applies on every write and normalizes on every read:

Pre-v9 spelling(s)v9 canonical value
allowallowed
deny, deniedblocked
modified (the old frontend display value for a masked decision)redacted
require_approval, pending_approvalneeds_approval
(none — new in v9)redacted as a first-class value distinct from allowed
(none — new in v9)error for uncomputable / unrecognized decisions

Notes:

  • allow / deny were the wire verdict tokens that some write paths used to persist verbatim. They are now translated to allowed / blocked at every audit-write boundary, so they no longer land in audit_logs. They remain valid wire verdicts — see the distinction above.
  • Historical audit_logs rows carrying the old spellings are backfilled to canonical values by migration 122, so existing history reads consistently with new rows.
  • A database CHECK constraint (migration 123) now enforces the canonical set at write time — see Self-hosted: the audit_logs CHECK constraint below.

GET /api/v1/decisions — read values and filter​

The decisions read API moved to the canonical vocabulary. This is the surface most likely to affect a programmatic consumer.

Response values​

The decision field on each item returned by GET /api/v1/decisions (the list feed) is now one of the canonical values — allowed / blocked / redacted / needs_approval / error — for all rows, including historical ones, because the list endpoint normalizes the value on read. Before v9 this field could surface allow / deny / require_approval.

GET /api/v1/decisions/:id/explain returns the recorded policy_decision for a single decision verbatim. For decisions made on v9 this is already one of the canonical values (every forward writer now persists canonical); for a pre-v9 historical decision, explain surfaces the original recorded spelling (e.g. allow / deny / require_approval) — it is not normalized on read. If you read individual decisions via explain and need a uniform vocabulary, normalize the value on your side, or use the list feed (which is uniformly canonical).

Filter values — this one returns an error if you pass the old values​

The ?decision= query-string filter on GET /api/v1/decisions now accepts only the canonical set. Passing a pre-v9 value is rejected:

GET /api/v1/decisions?decision=deny
→ 400 Bad Request
{"error":"decision must be one of: allowed, blocked, redacted, needs_approval, error"}

Update any filter call accordingly:

Pre-v9 filter valuev9 filter value
?decision=allow?decision=allowed
?decision=deny?decision=blocked
?decision=require_approval?decision=needs_approval
(was rejected with 400 before v9)?decision=redacted, ?decision=error now valid

The customer-portal audit view's decision filter (the ?action= parameter on the portal audit page) uses the same canonical set.

Regulator export record values (SEBI, EU AI Act)​

If you generate SEBI or EU AI Act compliance exports, the decision-outcome field on each exported record now maps cleanly from the canonical audit label:

Canonical audit labelExported outcome (SEBI & EU AI Act)
allowedapproved
blockedblocked
redactedredacted
needs_approvalpending_review
errorerror

Before v9, the export mapping was keyed on the present-tense allow / deny tokens, so a canonical allowed / blocked row fell through unmapped and could leak a raw allowed into a regulator-facing record, and a human-deferred needs_approval decision could be reported without its requires-review flag set. v9 maps every canonical value explicitly and flags needs_approval as pending_review (requires-review) consistently.

HITL: timeouts now record expired, not rejected​

When a human-in-the-loop approval times out without a reviewer acting, AxonFlow now records the terminal status expired instead of rejected. This distinguishes an automatic timeout from an explicit human rejection — important for audit and for EU AI Act human-oversight reporting, where the two are materially different outcomes.

  • The expired value already existed in the approval-status enum (pending / approved / rejected / expired) and the review-decision enum (approved / rejected / overridden / expired); what changed in v9 is that a timeout now lands in expired rather than masquerading as rejected.
  • If you poll approval status or bucket approval outcomes, treat expired as a distinct terminal not-approved state. AxonFlow's own EU AI Act human-oversight metrics already bucket expired separately from rejected and exclude it from reviewer-response-time averages.

Self-hosted: the audit_logs CHECK constraint​

v9 adds a database CHECK constraint (migration 123) enforcing that audit_logs.policy_decision is one of allowed, blocked, redacted, needs_approval, error, or override_lifecycle. This affects you only if you maintain a self-hosted source fork with customized write paths that insert audit rows directly:

  • The migration first normalizes any residual non-canonical rows (mirroring the old → new map above, with anything unrecognized failing safe to error), then adds the constraint — so applying it against existing history is safe and does not reject legitimate rows.
  • After v9, a customized writer that inserts a non-canonical policy_decision will fail loudly with a constraint violation at the database, instead of silently corrupting block-rate and compliance metrics downstream. If you fork and write audit rows, emit one of the six canonical values.
  • The migration is idempotent and its down step drops the constraint (the data normalization is forward-only — the original pre-canonical spellings are not recoverable, and the canonical set is the intended steady state).

Stock self-hosted deployments — and any deployment that writes audit rows only through the shipped AxonFlow code — need no action; the shipped writers already emit canonical values.

SDK contract — what's affected and what isn't​

The precise statement, by surface:

  • Enforcement (wire) contract — unchanged. SDK decide calls read the wire verdict allow / deny / needs_approval; check-input / check-output enforcement calls read a boolean (allowed / approved) plus redaction fields. These shapes and values are frozen — no SDK code change is required to keep enforcing decisions on v9, and the SDK config field names, Basic Auth, and request/response JSON shapes are unchanged. (Where an SDK governance-response type exposes a nested per-policy decision detail — for example the TypeScript PolicyDecision.decision with values allow / deny / modify — that is the policy-action result, not the audit_logs.policy_decision column, and it is unchanged by v9.)
  • Audit-search read methods — unaffected. The SDK audit-log entry type (AuditLogEntry in every SDK) does not carry the policy_decision string — it exposes blocked / success booleans and the violated policy IDs — so the value-map change does not touch it.
  • /api/v1/decisions read wrappers — values and documented filter changed. The SDK helpers that wrap the decisions read API (list_decisions and the decision-explain call, and their DecisionSummary / DecisionExplanation result types) carry the decision value verbatim from the platform: the list summary is canonical for all rows (the list endpoint normalizes on read), while the explain call returns the recorded value (canonical for v9-era decisions, the original spelling for pre-v9 historical ones). The documented filter values move from allow / deny / require_approval to allowed / blocked / redacted / needs_approval / error. The SDK forwards the filter string verbatim (no client-side validation), so it does not break at compile time — but a call passing an old filter value will receive a 400 from the v9 platform, and code that string-matches the returned decision value should be updated to the canonical spellings. (The SDK result-type doc-comments and the list_decisions examples still show the pre-v9 allow / deny / require_approval vocabulary and should be refreshed in a follow-up SDK docs update.)

If you only enforce decisions, you are done. If you read the decisions feed through an SDK, update your filter values and any string comparisons against the returned decision to the canonical set.

Field-by-field cheat sheet​

For programmatic consumers:

Where you see itPre-v9 value(s)v9 value(s)Notes
POST /api/v1/decide response decisionallow | deny | needs_approvalunchangedWire/PEP enforcement verdict — frozen
Check-input / check-output responseallowed / approved boolean + redaction fieldsunchangedBoolean-shaped; no decision string field
audit_logs.policy_decision columnallow, deny, denied, require_approval, modified, …allowed | blocked | redacted | needs_approval | errorCanonical; CHECK-enforced; history backfilled
GET /api/v1/decisions item decision (list)allow | deny | require_approvalallowed | blocked | redacted | needs_approval | errorNormalized on read (all rows)
GET /api/v1/decisions/:id/explain item decisionallow | deny | require_approvalcanonical for v9 rows; raw recorded value for pre-v9 rowsNot normalized on read
GET /api/v1/decisions?decision= filterallow | deny | require_approvalallowed | blocked | redacted | needs_approval | errorOld values now 400
SEBI / EU AI Act export outcomecould leak raw allowed; review flag could be unsetapproved | blocked | redacted | pending_review | errorneeds_approval → pending_review
HITL approval timeout terminal statusrejectedexpiredDistinguishes timeout from explicit rejection

What you need to do — by consumer type​

SDK / plugin / HTTP API enforcement consumers​

Nothing. You read the wire verdict (allow / deny / needs_approval); it is unchanged.

Consumers that read the decisions feed or audit label​

  1. Update any GET /api/v1/decisions?decision= filter from allow / deny / require_approval to allowed / blocked / needs_approval.
  2. Update any code that string-matches the returned decision value (or a stored policy_decision) to the canonical set.
  3. If you have stored historical decision labels outside AxonFlow, apply the old → new value map to reconcile them with v9 reads.

Compliance / regulator-export consumers​

Re-pull any in-flight SEBI / EU AI Act export after upgrading to pick up the corrected outcome mapping (no raw allowed leakage; needs_approval → pending_review). Treat HITL expired as a distinct terminal not-approved outcome.

Self-hosted source forks​

Audit any customized write path that inserts audit_logs rows: emit one of the six canonical policy_decision values, or the v9 CHECK constraint (migration 123) will reject the write. Stock writers already comply.

Getting help​

If a decisions-feed call or an export started behaving differently after a v9 upgrade, the most useful information for support:

  1. The exact endpoint and query string (e.g. GET /api/v1/decisions?decision=…).
  2. The HTTP status code and response body.
  3. The decision value(s) you expected versus what you received.

[email protected] — please mention "v9 migration" in the subject so it routes correctly.

See also​