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_approvalwire verdict; the audit decision label and the/api/v1/decisionsread 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 verdict | Audit label (policy_decision) | |
|---|---|---|
| Where you see it | POST /api/v1/decide response decision field | audit_logs.policy_decision column; GET /api/v1/decisions; regulator exports; portal audit view |
| Values | allow | deny | needs_approval | allowed | blocked | redacted | needs_approval | error |
| Purpose | The instruction your PEP enforces in real time | The persisted, reportable record of what happened |
| Changed in v9? | No — frozen caller contract | Yes — 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 value | Meaning |
|---|---|
allowed | The 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. |
blocked | The request/response was denied; nothing reached the downstream model/tool, or the response was withheld. |
redacted | The request/response was permitted but modified (PII masked, fields stripped). Distinct from allowed so a redaction is never mislabeled as a clean allow. |
needs_approval | The decision is deferred to a human (HITL); neither allowed nor blocked yet. |
error | The 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_lifecycle | A 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 |
|---|---|
allow | allowed |
deny, denied | blocked |
modified (the old frontend display value for a masked decision) | redacted |
require_approval, pending_approval | needs_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/denywere the wire verdict tokens that some write paths used to persist verbatim. They are now translated toallowed/blockedat every audit-write boundary, so they no longer land inaudit_logs. They remain valid wire verdicts — see the distinction above.- Historical
audit_logsrows carrying the old spellings are backfilled to canonical values by migration 122, so existing history reads consistently with new rows. - A database
CHECKconstraint (migration 123) now enforces the canonical set at write time — see Self-hosted: theaudit_logsCHECK 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 value | v9 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 label | Exported outcome (SEBI & EU AI Act) |
|---|---|
allowed | approved |
blocked | blocked |
redacted | redacted |
needs_approval | pending_review |
error | error |
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
expiredvalue 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 inexpiredrather than masquerading asrejected. - If you poll approval status or bucket approval outcomes, treat
expiredas a distinct terminal not-approved state. AxonFlow's own EU AI Act human-oversight metrics already bucketexpiredseparately fromrejectedand 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_decisionwill 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
downstep 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
decidecalls read the wire verdictallow/deny/needs_approval;check-input/check-outputenforcement 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-policydecisiondetail — for example the TypeScriptPolicyDecision.decisionwith valuesallow/deny/modify— that is the policy-action result, not theaudit_logs.policy_decisioncolumn, and it is unchanged by v9.) - Audit-search read methods — unaffected. The SDK audit-log entry type (
AuditLogEntryin every SDK) does not carry thepolicy_decisionstring — it exposesblocked/successbooleans and the violated policy IDs — so the value-map change does not touch it. /api/v1/decisionsread wrappers — values and documented filter changed. The SDK helpers that wrap the decisions read API (list_decisionsand the decision-explain call, and theirDecisionSummary/DecisionExplanationresult types) carry thedecisionvalue 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 fromallow/deny/require_approvaltoallowed/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 a400from the v9 platform, and code that string-matches the returneddecisionvalue should be updated to the canonical spellings. (The SDK result-type doc-comments and thelist_decisionsexamples still show the pre-v9allow/deny/require_approvalvocabulary 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 it | Pre-v9 value(s) | v9 value(s) | Notes |
|---|---|---|---|
POST /api/v1/decide response decision | allow | deny | needs_approval | unchanged | Wire/PEP enforcement verdict — frozen |
| Check-input / check-output response | allowed / approved boolean + redaction fields | unchanged | Boolean-shaped; no decision string field |
audit_logs.policy_decision column | allow, deny, denied, require_approval, modified, … | allowed | blocked | redacted | needs_approval | error | Canonical; CHECK-enforced; history backfilled |
GET /api/v1/decisions item decision (list) | allow | deny | require_approval | allowed | blocked | redacted | needs_approval | error | Normalized on read (all rows) |
GET /api/v1/decisions/:id/explain item decision | allow | deny | require_approval | canonical for v9 rows; raw recorded value for pre-v9 rows | Not normalized on read |
GET /api/v1/decisions?decision= filter | allow | deny | require_approval | allowed | blocked | redacted | needs_approval | error | Old values now 400 |
| SEBI / EU AI Act export outcome | could leak raw allowed; review flag could be unset | approved | blocked | redacted | pending_review | error | needs_approval → pending_review |
| HITL approval timeout terminal status | rejected | expired | Distinguishes 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
- Update any
GET /api/v1/decisions?decision=filter fromallow/deny/require_approvaltoallowed/blocked/needs_approval. - Update any code that string-matches the returned
decisionvalue (or a storedpolicy_decision) to the canonical set. - 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:
- The exact endpoint and query string (e.g.
GET /api/v1/decisions?decision=…). - The HTTP status code and response body.
- 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
- v7 → v8 Migration Guide — the previous major upgrade (identity-model and Row-Level Security cleanup).
- Deployment Mode Matrix — self-hosted, Evaluation, Enterprise, SaaS, and In-VPC fit.
- Failure Modes And Recovery — degraded-provider, connector, approval, and runtime behavior.
