Skip to main content

AxonFlow v10.4.0 Release Notes

v10.4.0 is a minor. Its centre of gravity is a single idea: an enforcement point should be able to say what it can actually do, and the platform should stop handing it obligations it cannot carry out.

Until this release, an AxonFlow policy enforcement point - a plugin, a gateway adapter, an SDK-driven service - could identify itself by library name and version and nothing else. If a decision carried a mandatory redaction obligation, the platform emitted it and hoped. If that enforcement point had no way to substitute the platform's masked content for the original, the request proceeded with the original while the record said a redaction applied. v10.4.0 closes that: the enforcement point declares its capabilities per request, and on Enterprise a mandatory obligation nothing can discharge is refused rather than emitted.

Nothing about that reaches an existing deployment. The declaration rides an optional request header. An absent header is byte for byte the previous behaviour, every client that shipped before this release sends nothing, and every client that ships with this release still sends nothing until a deployment sets one new variable. So the release adds a refusal that an operator has to ask for twice.

It is a MINOR by the semver policy. Five additive migrations run, and one of them is core, so a Community deployment does apply a migration this release. No capability is removed, no minimum client version moves, and no existing endpoint changes its status or verdict for a caller that changes nothing.

Am I affected?

A deployment that upgrades and changes nothing else. Applies the migrations below and behaves as on v10.3.0 on every request path. /health grows one entry in its capability list and one new counter appears on /prometheus, both describing a feature nothing is using yet.

Anyone running an AxonFlow plugin, gateway adapter or SDK-driven enforcement point. New client releases ride this train and each can present a capability declaration - but only when you set AXONFLOW_PEP_AUDIENCE, which nothing sets by default. Read The PEP capability handshake before you set it, because on Enterprise it can turn an allow into a deny, and that is the point of the feature rather than a side effect of it.

Anyone authoring policy through the customer portal, on Enterprise. A typed authoring surface is available behind a per-organization flag that is off by default. Nothing changes until an operator enables it for an organization.

Anyone who reads /health to discover what a platform supports. The capability list is no longer hand-maintained. It is projected from one registry, so it cannot silently omit a shipped surface - which it had done for four consecutive releases. Existing entries are unchanged, in the same order, with the same descriptions; one entry is added.

Anyone on a Community build who was expecting the refusal. The declaration is read, validated, bound to an identity, narrowed and counted in every edition. Deciding on the strength of it is Enterprise. A Community deployment emits the obligation exactly as it does today, and the enforcement point fails closed at its own seam - which is unchanged, not weaker.

Anyone whose GCS connector uses credentials_file or credentials_json. Those are documented as service-account keys and are now loaded as exactly that, where the underlying helpers previously accepted any credential configuration without the caller stating what it expected. A configuration that is not a service-account key, and happened to work, will now be refused - and the file is read eagerly, so a bad path fails when the connector is built rather than on its first call.

Anyone on the Evaluation tier relying on approvals expiring by themselves. They have not been. On any deployment running with the application database role, the auto-expiry swept zero rows and reported success, silently, since v9. Approvals that should have aged out have been sitting in the queue. See Corrections.

Anyone scraping /prometheus. New counters appear for handshake outcomes and capability refusals. No existing series changes.

Anyone reading the anonymous startup heartbeat's disclosure. It gains two additive dimensions - the build's edition, and this deployment's own mode - so adoption can be counted per build rather than guessed. No prompts, payloads, identifiers or customer data, and AXONFLOW_TELEMETRY=off suppresses it as before.

Everyone else. Nothing changes.

The PEP capability handshake

What a declaration is

An enforcement point presents, on every governed call:

X-Axonflow-PEP-Handshake: <base64url(compact JSON)>

The document names the enforcement point and lists the obligation types and schema versions it can discharge. The header is optional, and the distinction between absent and empty is load-bearing in both directions:

  • absent - the legacy caller. Today's behaviour, byte for byte.
  • present and empty-valued - malformed, and refused. A header that exists but says nothing is a client bug, not a declaration.
  • present with an empty capability list - a real declaration that says "I can discharge nothing". That is a meaningful and often correct answer, and it is treated as one.

Why "I can discharge nothing" is frequently the honest answer

Discharging a field_redact obligation means substituting the platform's engine-masked content for the original. It does not mean redacting the content yourself: a client may not decide what its own masked text looks like, because the decision record would then describe a redaction the platform never performed.

So an enforcement point that never performs that substitution declares the empty set. On an Enterprise platform it is then denied a request carrying a mandatory redaction obligation, rather than allowed on the strength of a substitution nobody performs. Several clients shipping with this release declare exactly nothing, and that is the accurate declaration rather than a shortfall.

One vocabulary, three planes, three different discharge mechanics

The header is read on three enforcement planes, and one declaration satisfies all three - there is no per-plane dialect. What differs is how a required redaction reaches the caller, and an integrator must not assume one plane's shape from another's:

POST /api/v1/decide (and its AuthZEN adapter)The MCP planeThe gateway pre-check plane
How the redaction reaches the calleremits an obligation naming a fulfillment endpoint the caller then callsis that endpoint - returns the masked text inlineissues an instruction (requires_redaction) for the caller to act on
What a refusal withholdsthe obligationthe masked contentthe instruction as well as the content
Preconditionnonenoneresolves a per-user identity first

The last row is the one that bites in testing. The pre-check plane resolves a per-user identity before any policy work, so a request that omits the user token measures the authentication boundary and never reaches the capability gate at all. A failing integration test there is usually a missing token, not a broken handshake.

What refuses, and what merely narrows

SituationOutcome
Mandatory obligation outside the declared setDenied, reason pep_capability_unsupported. The refusal withholds the instruction as well as the content - telling a caller that cannot discharge an obligation to go and discharge it anyway is the confusion the refusal exists to remove. Enterprise only.
Header unreadable as a handshake documentRefused, reason pep_handshake_malformed. Every edition.
Authenticated channel carries no client identityRefused, reason pep_handshake_identity_unbindable. The document was fine and the channel was not, so it is a distinct reason - an operator sent to look at the client's code would find nothing wrong with it. Every edition.
A capability claimed beyond what this deployment's edition can holdNot refused. The claim is dropped and the request proceeds on the narrowed set. Every edition.
No headerNothing. The legacy caller, served as before.

Every outcome is counted, including absence, because a window with no malformed handshakes is equally well explained by a correct fleet and by a fleet presenting nothing at all.

Two wire members changed with it

Both are the difference between absent and empty, one level down from the header itself:

  • fulfillment_capabilities can now be SENT as an empty list. It was a plain array that omitted itself when empty, so a client could not express "I have a seam and it can discharge nothing" - the encoder produced the same bytes for that as for saying nothing at all. It is now nullable on both sides, so the two are distinguishable on the wire. Its meaning is deliberately unchanged: absent and empty still both read as the legacy caller for this member, because changing that reading would widen a security control for a caller that could always send those bytes.
  • A declared-empty capability set no longer serialises as absent. An enforcement point that declares it discharges nothing rendered "capabilities": null - an absent member - which is the one thing a declaration must never be confused with.

Enabling it

  1. Upgrade the platform to v10.4.0.
  2. Upgrade the client to the version that presents a declaration (see Client versions).
  3. Set AXONFLOW_PEP_AUDIENCE on that client. The audience is what a decision proof is bound to, and only the deployment knows it, which is why there is no default.
  4. On Enterprise, expect a client declaring nothing to be denied a request carrying a mandatory redaction obligation, where before it was allowed. Roll it out where you can observe that. On Community nothing is denied.

Discovered on /health as the capability pep_capability_handshake.

Typed policy authoring in the portal

Enterprise, behind a per-organization flag, off by default.

The customer portal can author typed policy documents rather than free-form rules, and it draws grants and ceilings as visibly different things - a permission that allows something, and a constraint that bounds it. Conflating the two is the mistake the visual distinction exists to prevent: a reader who cannot tell a permission from a limit cannot tell what a policy set actually permits.

It has its own route, /policy-authoring, with a nav entry under Governance and a pointer card on the Policies page. That placement is deliberate rather than cosmetic. The editor is a preview of the v11 authoring model; the legacy dynamic-policy path on the Policies page is what still ENFORCES, and putting a preview above an enforced surface would misstate which of the two is real. So the Policies page is unchanged and the editor lives beside it, not on top of it. The route re-reads the per-organization flag itself rather than trusting the navigation, because a URL is typeable.

Groups are picked from the directory rather than typed: the realm half of a Group::realm:name identifier is not guessable, and one the registry does not know is not a policy that fails loudly - it is a policy that matches nothing. Publishing a policy no longer means hand-writing JSON. The publish dialog has a test-case builder: subject, action, resource and context are filled in as fields, against the same shapes the policy itself is typed against, instead of a free-text area where a malformed case reads as a failing one.

And a brand-new grant no longer reads as one that never applies. The editor evaluated an unsaved grant against the empty set and reported, correctly but uselessly, that it matched nothing - which an author reasonably read as "this rule is broken" rather than "this rule has not been saved yet".

Nothing changes for an organization whose flag is unset, which is every organization on upgrade.

/health capabilities can no longer go stale

The capability list /health serves used to be a hand-maintained list in the source, and a hand-maintained list is bounded by whoever last remembered to edit it: four consecutive releases shipped without a single new entry, including a public route that every SDK calls.

The SDK version floors served beside that list are single-sourced too. They were a hand-maintained map in each of the two planes and both are served, so a client asking the agent which SDK version it needed had no structural reason to get the same answer as one asking the orchestrator. Both now return the same map by construction, and the published API documents are checked against it - they had drifted.

It is now projected from one capability registry. Every existing entry is byte-identical - same name, same since, same description, same order, because clients index that array - and one entry is added, pep_capability_handshake. There is nothing left to forget to edit, and a registered route with no capability entry now fails the build.

Client versions

An SDK and client release train rides this platform release.

ClientVersionPresents a declaration of
Python / TypeScript / Go / Java SDK9.3.0read-path identity scoping; the Python SDK adds the per-call extra_headers parameter a declaration is presented through
Rust SDK (preview)0.10.0telemetry parity with the other four
openclaw plugin2.9.0field_redact on both its request and response paths
Claude Code plugin1.12.0nothing - it submits a statement and acts on the verdict; it performs no substitution
Cursor plugin1.8.0nothing, same reason
Codex plugin1.8.0nothing, same reason
Claude Desktop proxy0.4.0field_redact, or nothing when response redaction is switched off
n8n node1.2.0nothing - it returns the platform's response to the workflow, which decides; a node cannot promise a substitution on a workflow's behalf
Google ADK plugin1.2.0field_redact on its response path, nothing on its request path
LiteLLM integration1.1.0reaches the platform through the Python SDK; per that SDK's declaration

Minimum client versions are unchanged - SDKs v8.0.0 (Rust v0.7.0), plugins v1.4.0 (openclaw v2.4.0, Claude Desktop v0.2.0). A client below the recommended version keeps working and simply presents no declaration, which is the same position as every client released before v10.4.0.

Two of these clients present two declarations under separate identifiers, because one process can be two enforcement points whose paths discharge different obligations. A single document for both would over-claim on one path or under-claim on the other.

Migrations

Five, all additive. One is core, so a Community deployment applies one this release - which was not true of v10.2.0 and was true of v10.3.0.

MigrationEditionWhat it does
core/170every deploymentCompletes the application grants on the force-RLS tables it can name, as defence in depth. No data mutation.
enterprise/151EnterpriseThe same grant backfill for the Enterprise tables.
enterprise/152EnterpriseA retention horizon on settled reservation rows, so the table has a bound.
enterprise/153EnterpriseAdds the per-plane column behind narrowing decision shadow to some planes for one organization.
enterprise/154EnterpriseAdds the per-organization typed-authoring flag.

None rewrites a table, none backfills row-by-row, and none takes a long lock. Rolling the binaries before applying any of them keeps v10.3.0 behaviour.

Other surfaces a client or operator can see

The gateway PEP adapters are now source-available under BSL 1.1. The agentgateway and Envoy adapters move into the platform tree and the community mirror carries them, so the import path is axonflow/platform/gateway-adapters and the source is readable alongside everything else. They were previously in the enterprise-only tree.

decision-replay, a new command: reproduce a recorded decision offline. Given a normalized input and the pinned policy bundles, it re-derives the verdict with nothing running - no database, no agent, no network. It exists so that a decision can be examined after the fact without reconstructing the stack that produced it, and so that "would this input have been decided the same way" is a question with an answer rather than an argument.

A challenge now carries its obligations onto the AuthZEN wire. The serving path dropped them: the gate deciding whether a decision carries instructions was written as "allow", which is narrower than the rule it was meant to encode - a challenge IS a permit whose precondition has not yet been met, so it carries instructions too. A caller negotiating the AxonFlow profile and receiving a challenge now sees what it is required to do about it.

The AuthZEN evaluation route's own documentation is corrected. Its 500 refusal is now declared - it was emitted and undeclared, which a generated client discovers at runtime - and the spec says why the 401 is a platform envelope rather than an AuthZEN error. The published contract also declares the bulk-entry bound the server has been enforcing.

Rollout levers this release adds

Two narrowings, both for the operator who needs to mitigate one thing without switching off everything:

  • Decision shadow can be narrowed to some planes, for one organization. Previously the choice was all twelve planes or none.
  • The identity compatibility adapters gain a per-path rollback lever, so one credential path can be taken out of enforcement without taking out the others.

And two tightenings on the same axis. Per-organization enforce on the identity axis is granted only against a measured shadow window - asking for enforcement on an axis nobody has observed is refused rather than honoured. And a process-wide enforce is refused at boot, because a deployment-wide flag cannot carry the per-organization evidence that grant depends on.

Corrections worth knowing about

  • A custom-policy connector ceiling could be read from an unverified licence. The connector-entitlement path read a deployment mode it did not recognise as if it were Enterprise, and a separate path read a licence tier without verifying the licence. Both now fail closed to Community and count the refusal. If your deployment mode was neither community nor empty and you were relying on an unlimited connector list, your list may now be capped at the tier you actually hold. The limit that now applies is the Community custom-policy connector ceiling (MaxCustomPolicyConnectorsCommunity); count your per-organization connectors against it before upgrading rather than after.
  • Human approval decisions never appeared in the decisions feed. They do now.
  • Audit rows from the MCP seam lost their tool attribution to a string mismatch between what the adapter sent and what the platform compared against. Historic rows are unaffected; new ones carry the tool.
  • An EU AI Act accuracy alert could not be acknowledged or resolved through the surface that offers those actions.
  • Evaluation-tier approvals have not been expiring since v9, on any deployment running with the application database role. The sweep ran cross-tenant against a role that cannot see other tenants' rows under row-level security, so it matched nothing, reported success, and left approvals in the queue that should have aged out. Check the queue for approvals older than your expiry window before assuming the backlog is real work.
  • The approval queue guarded creation and left every state transition unguarded. The count of authored inserts had been pinned after two duplicated writers diverged unnoticed; the transitions - the half that decides whether an approval is granted, denied or expired - had no such guard. They do now.
  • A dependency carrying a denial-of-service advisory is upgraded; no source or behaviour change.

What this release deliberately does not claim

  • The handshake changes nothing until you opt in twice - a client upgrade and AXONFLOW_PEP_AUDIENCE. There is no default-on path to the new refusal.
  • On Community the deny does not exist. The declaration is read, validated, bound, narrowed and counted, and then the obligation is emitted anyway, exactly as before. That is today's safety posture, not a reduced one: the enforcement point still fails closed at its own seam rather than forwarding ungoverned content. What Enterprise adds is turning that into a refusal the platform can see, audit and count, reached before the content is held.
  • Typed authoring is a flagged preview, per organization, off by default.
  • The recommended client versions above are advertised on /health from this release. They are recommendations, not floors: nothing stops working at an older version.