Skip to main content

AxonFlow v10.0.0 Release Notes

v10.0.0 does two things. It closes eight enforcement gaps where a segment-scoped policy was filtered out before evaluation and the request simply passed, and it stops four read surfaces from publishing values the platform never measured: a latency average over nothing reported as 0ms, token and cost fields fabricated as 0, a compliance report that printed a score over sections it had failed to read, and a retention table that reported COMPLIANT over a configuration nobody had set. Underneath both, the orchestrator's second, in-memory dynamic-policy engine is deleted, leaving one engine and one code path.

It is a MAJOR by the semver policy: three fallbacks are removed, one at boot, one on the identity path where a presented-but-invalid per-user token used to be downgraded to a synthetic service identity, and one on the portal's SAML path where a fabricated, per-restart signing keypair stood in for a real one; a credential becomes required on two tools for some organizations, and can now be made required for an entire organization; and several read APIs change what they put on the wire when they have nothing to report. Eight migrations run this release: core/160 through core/167. core/160 deletes one seeded policy row. core/161 and core/162 are whole-table backfills of audit_logs, and a statement_timeout too tight for them is a boot loop rather than a skipped step, because the migration runner answers a migration error with a fatal exit. Size the timeout against your own audit_logs row count before upgrading, not after. core/163 and core/164 are cheap schema changes and do not enter that sizing. core/166 and core/167 hold ACCESS EXCLUSIVE while they run, 167 blocking reads on the approval queue. core/165 is the one migration that can change what your deployment enforces: it makes the organization key mandatory on the policy tables, and a row it cannot resolve an organization for stops being selectable. Read Before you upgrade in full, and the v9 to v10 migration guide alongside it.

Am I affected?

Every operator whose policy rows carry a tenant_id. Policy is now selected by organization, not by the tenant the caller names, so a rule scoped to one tenant starts applying to every tenant in its organization. The direction is over-blocking: a restriction applies more broadly and nothing stops being enforced. Two shapes matter and the second is the easier to miss - an organization whose rows target several different tenants, and an organization with three tenants and a single rule scoped to one of them, which carries no divergence among its own rows and still starts governing the other two. ./preflight.sh check 23 names both, by org_id / tenant_id / policy_id, before you pull the image. tenant_id is not removed; it stays on every row and in every audit record as attribution. What it no longer does is select policy.

Every operator with a policy row that has no organization key. Migration core/165 makes the organization key mandatory and stamps any row it cannot resolve one for with an internal sentinel the platform refuses on both sides of every comparison. A stamped row stops being able to fire. This is the one change in the release that removes enforcement rather than widening it, and the migration reports it in the agent's boot log during the upgrade, which is the wrong moment to learn it. ./preflight.sh check 24 reports the same rows read-only beforehand.

Every operator with a large audit_logs table. Two of the eight migrations rewrite it whole-table, in one statement inside one transaction each. Three more hold an exclusive lock that blocks reads while they run, and are sized separately. This is the first release in which a migration's runtime scales with audit history rather than with schema size. The migration guide carries read-only SQL you can run before upgrading to size both the scan and the row count each migration will rewrite.

Enterprise deployments that author segment-scoped policies. Those policies now enforce on eight planes that previously filtered them out before evaluation: the gateway pre-check plane, the workflow control plane's HTTP step-gate, the MCP-server check_policy and check_output tools, the four MCP REST routes, and POST /api/v1/decide. A policy that blocked a member elsewhere begins blocking them there. On the four MCP REST routes and POST /api/v1/decide there is a second new outcome to plan for: a caller with a validated per-user principal whose segment resolution errors is now refused with 403 and the guard identifier segment_resolution_failed, so a resolver outage denies those callers rather than passing them organization-only. Read the deliberate disclosure under Security too: coverage is now wide, but it is neither complete nor unconditional.

Anyone calling the MCP-server check_policy or check_output tools. X-User-Token becomes mandatory on those two tools for any organization holding an enabled segment-scoped policy for the matching phase. X-User-Email is explicitly refused as a substitute, and a token naming a shared synthetic identity is refused as well.

Enterprise callers that reach the platform without a per-user token. A new per-organization posture, require_user_token, refuses a token-less enterprise caller at authentication instead of serving it under a synthetic shared identity. It defaults to off and there is a deployment-wide default alongside it, so nothing changes until you opt in. Read Per-user identity can be made mandatory before you do, because turning it on is what makes a segment-scoped policy something a caller cannot shed by omitting a header.

Anyone whose per-user tokens can be invalid or revoked. On the four MCP REST routes, a presented token that fails validation is now refused with 401 rather than falling through to the same synthetic service identity as a caller who sent nothing. The practical consequence is that revoking a token now actually revokes it on that plane. A caller that has been relying on an expired or malformed token continuing to work will stop working.

Enterprise deployments whose tenants log in through SAML. The portal used to fabricate a throwaway signing keypair whenever the deployment-default SAML SP keypair could not be loaded from the secret store, and that fabricated keypair became the SP signing identity for every tenant whose SSO configuration stores no sp_private_key of its own. It is gone. A deployment whose tenants carry no per-tenant keypair and whose secret store the portal cannot read loses SAML login on upgrade, per tenant, rather than signing with an identity that changed on every restart. Confirm the secret loads, or provision per-tenant keypairs, before you upgrade. Read SAML no longer signs with a fabricated keypair for the full behavior.

Deployments running the customer portal on the application database role. The portal's organization-wide Executions read is now refused with 500 where no bypass-RLS admin pool is installed, instead of rendering a confident empty page, and AXONFLOW_DB_PLATFORM_ADMIN_URL is what installs that pool. Be precise about how a missing value presents, because it is louder and earlier than one failing route: the orchestrator refuses to boot when the app-role posture is on and that variable is blank, and crash-loops rather than starting. That boot refusal is not new in v10.0.0 and this release does not change it. The app-role posture counts as on unless AXONFLOW_DB_USE_APP_ROLE is set to one of exactly four values, false, FALSE, False or 0, so a deployment that has never set either variable is within its scope. Set AXONFLOW_DB_PLATFORM_ADMIN_URL before upgrading.

Anyone consuming the audit, usage or session-summary read APIs. avg_latency_ms becomes nullable, tokens_used, cost and response_time_ms are omitted rather than sent as 0, and POST /api/v1/audit/report now returns 500 where it previously returned a silently short table. A client binding any of those to a non-optional number will break.

Anyone integrating with the legacy SEBI audit export. POST /api/v1/sebi/audit/export gains a third terminal status and stops emitting a compliance score it cannot support. An integration keying on status === "completed", or binding compliance_score to a non-optional float, breaks on upgrade. The POST /api/v1/compliance/reports facade is a different contract and does not change; if you poll the facade, do not wait for a status transition to learn that a pack is incomplete.

Anyone with dashboards or alerts on the policy-condition metric. The plane="memory" label value of axonflow_policy_condition_unevaluable_total is no longer emitted, so a panel or alert matching on it will go permanently and silently empty rather than erroring. Nothing can detect this for you; sweep your own dashboards.

Anyone storing declarative workflow run ids. Ids returned by POST /api/v1/workflows/execute change prefix from wf_ to wfe_. Ids minted before this release keep their old prefix, and no row is rewritten.

Deployments with AXONFLOW_DEBUG_POLICIES set. The variable no longer exists. It only ever controlled the deleted engine's verbose logging, and setting it now does nothing. Remove it so your configuration stops implying a behavior that is gone.

Community edition: this is a substantive release, not a lockstep version bump. The single-engine consolidation, the computed risk score, the deterministic evaluation order, the audit read-API changes, the workflow identifier work and the execution-read refusals are all core code and ship in both editions from the same source. The segment-enforcement refusals are Enterprise-only in effect, because a Community build has no identity-attribute resolver and cannot reach them.

Governance segments: the gateway, workflow and MCP-server planes

(Enterprise)

Three of the eight enforcement planes evaluated policy with a hardcoded empty segment set. Because the shared engine excludes every segment-scoped policy row when the caller's segment set is empty, a policy that blocked a member on the request path was silently unenforced on those three planes and the call passed. The other five are covered under Per-user identity can be made mandatory below. All three now resolve the caller's segments before evaluation, through the same shared resolver the agent proxy and multi-agent planes already used, and all three fail closed when resolution errors.

The gateway pre-check plane. POST /api/policy/pre-check resolves segments once, before static evaluation. When the resolver errors the request is refused: the response carries an unapproved verdict and the reserved policy identifier segment_resolution_failed. It is an HTTP 200 carrying a deny verdict, not an HTTP error, so a client must key on the approval boolean and on that reserved identifier. A successful resolution that returns no segments is the ordinary case and proceeds organization-only. No new credential is required on this plane.

The workflow control plane's HTTP step-gate. The step-gate built its policy request with a tenant and nothing else, so the shared gate's "no verified identity" early return fired on every step. The gate now carries the organization and the caller email through to evaluation. A segment-resolution failure returns a block decision with the policy identifier segment_resolution_failed, again as an HTTP 200 body. The refusal cannot be cleared by a policy override: it is checked before the generic not-allowed handling and carries no applied-policy detail for an override to match. X-User-Email is newly read on the step-gate and on both resume routes; omitting it is not an error and degrades to organization-only, and the header is honoured only behind the existing internal-service proxy-authentication gate.

The MCP-server policy tools, with two distinct refusals. check_policy and check_output now resolve segments before evaluating, and refuse in two separate cases. Be precise about which one is conditional, because they size differently:

  • segment_resolution_failed is unconditional. A caller presenting a validated per-user token whose segment resolution fails is denied, whether or not the organization holds a single segment-scoped policy, and regardless of detection posture. If your MCP callers hold per-user tokens, a resolver outage denies them even on an organization with detection disabled and zero segment-scoped policies.
  • segment_identity_unresolved is conditional. A caller with no validated per-user principal is denied only when the effective policy set for that organization and phase actually holds an enabled segment-scoped row, or when that policy set could not be read at all. The second trigger matters for sizing blast radius: a database or policy-plane outage refuses these callers even on an organization that holds no segment-scoped policy, because whether the verdict depends on segments is then unknown.

Both refusals are an HTTP 200 JSON-RPC result carrying an unallowed verdict, a block reason, a decision id and a blocked_by identifier, and both write a canonical blocked audit row. Where an organization holds a segment-scoped policy for the phase, X-User-Token is now mandatory on those two tools.

Match on the identifier, never on the reason text. The human-readable reason strings in the "segment resolution unavailable" family are not punctuated consistently across the planes that emit them, so a grep or a string-equality check written against one plane's wording silently misses another's. blocked_by, and the equivalent policy-identifier field on the other two planes, are stable identifiers and are what an integration should match on. Normalizing the punctuation is tracked separately.

Community builds cannot reach any of these refusals. A Community build has no identity-attribute resolver at all, so segment resolution proceeds without resolving and a Community deployment sees no change on any of the three planes.

Per-user identity can be made mandatory

(Enterprise)

A segment-scoped policy is only meaningful if a caller cannot choose to arrive without an identity. Until this release a token-less enterprise caller on POST /api/v1/decide, the MCP-server session-authentication plane and the four MCP REST routes was handed a synthetic, organization-scoped service identity, so dropping a header quietly switched a segment-scoped control off. The fix belongs at authentication rather than in the policy evaluator, and that is where it now sits.

A new per-organization posture, require_user_token, makes the token mandatory. Migration core/163 adds the column with a default of false, so nothing changes at deploy time and an organization that never touches the lever keeps its current behavior. A deployment-wide default is available as AXONFLOW_REQUIRE_USER_TOKEN, which accepts true, 1 or yes and false, 0 or no, case-insensitive, and defaults to false. An explicit per-organization value wins over the deployment-wide default in either direction, so an organization can opt out of a true default exactly as it can opt in over a false one.

With the posture on, a token-less enterprise caller is refused at authentication rather than served. There are six gate points: POST /api/v1/decide, the MCP-server session-authentication plane, and the four MCP REST routes POST /mcp/resources/query, POST /mcp/tools/execute, POST /api/v1/mcp/check-input and POST /api/v1/mcp/check-output. On /api/v1/decide and the four REST routes the answer is 401, audited under its own marker, user_token_required. The MCP-server session-authentication plane answers 401 too, on every one of its exits, but it answers differently in two ways an integration has to allow for. The shape is not the platform's ordinary error envelope: session initialization and every subsequent tool call return the 401 as a JSON-RPC error body carrying a WWW-Authenticate header, and the session-delete route returns a bare 401 with no body at all. And no user_token_required audit marker is written on that plane, so a search for the marker turns up the decision endpoint and the four REST routes and nothing else; on the MCP-server plane the 401 itself is the signal. On that plane the condition is also stricter: it is that no validated per-user identity was produced, deliberately not merely that the field was empty, so a junk string presented on a deployment whose token validators failed to register is refused too instead of falling through to the pseudo-identity path. One caveat on how quickly the posture takes hold there. The gate runs during session authentication, and a request carrying an established session id is served from the session cache without re-authenticating, so a session opened before the posture was switched on keeps working until it falls out of that cache, which is a 24 hour idle timeout. Switching the posture on closes new sessions immediately and existing ones over that window.

Resolution fails closed. Where the posture cannot be read at all, because the database is unreachable or the column is absent, resolution returns "required" rather than "not required", and that outcome is cached for at most 15 seconds so a failing database is not queried on every request. Two cases are deliberately not failures and fall through to the deployment-wide default instead: an organization row that is genuinely absent, which simply means no per-organization posture is set, and a deployment with no database wired at all, which has no posture store to read. A successful read is cached for AXONFLOW_REQUIRE_USER_TOKEN_TTL_SECONDS, which defaults to 60 seconds and is clamped to between 5 and 600. Resolution never issues a per-request query.

An unparseable AXONFLOW_REQUIRE_USER_TOKEN value refuses the boot on an agent wired to a database. This is the one direction in which an unresolvable input would otherwise fail open. A value such as enabled is not recognized and would fall back to the default, silently turning the control off across every gate point for a deployment whose admin set the flag intending the opposite. Both guesses are wrong in a way that is invisible afterwards, so the process logs a fatal line naming the variable and the accepted values instead of guessing. A deployment that never sets the variable is unaffected; only a set-but-unintelligible value reaches this. Be precise about the one shape the guard does not cover. It runs inside the same database-backed startup step that wires the posture cache, and that step returns early on an agent with no database, which is also the configuration in which the posture has no store to read. On such an agent an unparseable value does not refuse the boot and resolution falls back to the environment default silently, which is exactly the failure mode the guard exists to prevent. The posture itself is unreachable there, but do not treat a clean boot on a database-less agent as proof that the value parses.

A presented-but-invalid per-user token is refused rather than downgraded. On the four MCP REST routes, a token that fails validation, whether malformed, expired, signed with the wrong algorithm, carrying a bad signature or revoked by identifier, used to fall through to the same synthetic service identity as a caller who sent nothing, so the request proceeded under a weaker identity than the one it presented. It is now audited as user_token_rejected and answered with 401. This is a removed fallback and one of the changes forcing the major. Its practical consequence is worth stating on its own: revoking a per-user token now actually revokes it on this plane, where previously it downgraded the caller instead. The absent-token and rejected-token causes are kept under separate audit markers so they never collapse into one.

Segment enforcement reaches five more planes. The four MCP REST routes and POST /api/v1/decide now resolve the caller's governance segments before evaluation, through the same shared resolver the three planes above use. Resolution is fail-closed for a verified caller: where the caller has a per-user principal and the resolver genuinely errors, the request is denied with the guard identifier segment_resolution_failed and 403, on its own channel rather than through the evaluation-unavailable path, so a deliberate policy-side deny stays distinguishable from a real orchestrator outage in both the audit row and the dashboard. Be precise about what stays conditional: a caller with no validated per-user principal, or one whose token names a shared synthetic identity, resolves to no segments and is evaluated organization-only, with no refusal. That is the ordinary compatibility case rather than a failure, and it is exactly what the require_user_token posture exists to close.

One new metric accompanies this work. axonflow_segment_subject_org_mismatch_total counts resolutions whose subject organization, taken from the validated token's own claim, disagreed with the authenticated organization of the credential the request arrived on. Such a mismatch cannot over-match, because segment identifiers are organization-scoped group identifiers and the asserted organization's groups can never match the governing organization's policies. What it can do is silently under-match: the lookup joins to zero rows, which reads as a successful empty resolution, and a verified member of a targeted segment is then evaluated organization-only, indistinguishable from a genuine non-member. It is reported rather than refused, because refusing would break exactly those deployments whose tokens default the claim. A non-zero value on this series means a segment-scoped policy is not restricting the members it names, and is worth an alert.

SAML no longer signs with a fabricated keypair

(Enterprise)

The customer portal loads a deployment-default SAML SP signing keypair from the secret store at startup. On any failure to load it, a missing secret, a denied read, a misconfigured region or a secret-store outage, it used to mint a throwaway RSA keypair and install that as the deployment-default SP signing identity. That fallback is deleted. It is the third of the three fallbacks that force the major.

The fabricated keypair was inherited by every tenant whose SSO configuration carries no sp_private_key of its own, and it made SAML look healthy: metadata served, assertions were signed, logins worked. What it could not do was stay the same. The SP certificate was regenerated on every portal restart, so an identity provider that pins it, through signed authentication requests, encrypted assertions or strict metadata pinning, began rejecting logins with a signature mismatch. That mismatch surfaced at the identity provider, mid-login, uncorrelated with a one-line boot warning from days earlier.

A tenant with no keypair of its own is now refused when its SAML configuration is loaded, not at boot. The refusal is deliberately scoped per tenant. Failing the whole SAML service at startup would have been the wrong fix, because the portal treats that failure as graceful degradation: it logs, continues without the multi-tenant SAML service, and registers no SAML route at all. That would have removed SAML for every tenant, including those that store their own keypair and were never on the fabricated one. A tenant that stores its own sp_private_key and sp_certificate is unaffected in every case. SP metadata for an affected tenant is refused for the same reason: serving metadata with no certificate, or with one that changes on every restart, is how the defect stayed invisible.

The refusal is deliberately terse where an unauthenticated caller can see it, and fully actionable where an admin reads it. The pre-authentication routes, login initiation, the assertion callback and tenant metadata, answer a generic message. The detailed text names the secret and wraps the raw cloud SDK error, which on an access-denied shape carries the portal's own principal identifier and account id, and none of that belongs in a response body served to an anonymous caller. Three surfaces carry the full text instead: the boot log, which states the remedy at startup; the portal log, which records the cause on every refused login; and the session-authenticated POST /api/v1/sso/config/test, which now answers 400 reporting that SAML would be refused for the tenant before anyone attempts a login, with the secret's name carried in the response's error_details field rather than in message. That route takes no tenant parameter and tests the calling session's own configuration, so on a tenant-isolated deployment it is a per-tenant check rather than a sweep, and it requires the sso:configure permission rather than merely an admin session. Every other SAML error keeps its existing wording unchanged.

Do this before you upgrade, not after. Confirm the portal can read the SAML SP keypair secret, or store a per-tenant sp_private_key and sp_certificate on each affected tenant's SSO configuration through the SSO configuration API and re-register the new SP certificate at the identity provider. A pre-upgrade check has one blind spot worth knowing. Whether a tenant stores a keypair is decided on the fields being non-empty; the runtime additionally parses them and discards what it cannot read, then refuses the tenant for storing nothing. So a stored-but-malformed PEM reports unaffected before the upgrade and is refused at login after it. Two rules keep a tenant out of that state: always send the full PEM for both fields on every update that touches either, and never write back an sp_certificate read from GET /api/v1/sso/config, which returns it masked, because storing the mask leaves a value that is non-empty and unparseable. A deployment whose tenants have no per-tenant keypair and whose secret store the portal cannot reach loses SAML login on upgrade, for exactly those tenants. That is the intended outcome rather than a regression, and it is stated plainly because the fabricated keypair made the same deployment look healthy while its logins were already failing at the identity provider.

One dynamic-policy engine, not two

(Community and Enterprise)

The orchestrator carried two dynamic-policy engines. A database failure at boot constructed a second, in-memory engine and left enforcement permanently on built-in defaults until somebody restarted the process. That engine is deleted and boot no longer falls back to it. The one remaining database-backed engine starts on the built-in defaults and promotes itself to database-loaded on the first successful load thereafter, with no restart. A later failed refresh is a no-op: the loaded policy set keeps enforcing and is never reverted to defaults, so a transient blip cannot silently widen what is enforced.

Three consequences an operator has to act on:

  • AXONFLOW_DEBUG_POLICIES no longer exists. It only ever controlled the deleted engine's verbose logging. Setting it now does nothing.
  • The plane="memory" label value of axonflow_policy_condition_unevaluable_total is no longer emitted. The remaining values (database, mcp, policy_test) are unchanged. A recording rule, dashboard panel or alert matching plane="memory" will go permanently empty rather than failing, which is why this needs a deliberate sweep.
  • Built-in default dynamic policies are no longer appended to a database-loaded set. The retired engine unioned the two on every load, so five built-ins were enforced twice under two identifiers, the in-process default and its seeded database equivalent, and the in-process copy could not be disabled by editing or deleting the database row. The engine now serves either the built-in defaults or a database-loaded set, never both.

Four health and correctness fixes ride along with the consolidation. Dynamic-policy evaluation order is now deterministic (priority descending, then creation time descending, then policy id ascending as a final tiebreak). The engine previously cached policies in a map and evaluated them with a plain range, which randomizes iteration order on every call; once a matched policy's risk-modifying action can raise a running score that a later policy's own condition reads, "later" being randomized meant the same request could reach a different allow or block verdict from one call to the next. The running risk score is clamped to the zero-to-one range once, after the policy loop, so multiple risk-modifying policies compose additively and are then clamped, rather than a value above 1 flowing verbatim into audit rows and the compliance evidence export. The /health endpoint's policy-engine component no longer reports unhealthy for a legitimate defaults-mode deployment: it now reflects whether a usable, non-empty policy set is being served rather than whether a database handle exists. And a zero-row policy load read through a restricted database role is no longer promoted: that shape produces no error and no rows, which is exactly what a missing tenancy setting looks like, so it is now refused as a failed refresh, the last-good policy set keeps enforcing, and the refusal is counted separately.

The engine's action switch also gained real implementations for warn, alert, log and redact. Six of the ten built-in default dynamic policies had inert actions: three configured a risk-modifier key the switch never read, and warn had no case at all. All four arms are now implemented as allow-but-annotate, the same signal-only pattern the approval action already used.

Four new metrics report the engine's health directly: axonflow_policy_set_source (whether the engine is currently serving defaults or a database-loaded set), axonflow_policy_cache_age_seconds, axonflow_policy_refresh_failures_total and axonflow_policy_zero_row_loads_total. They replace a fake health row that wrote refresh staleness into a column named for execution time, a value that was never an execution time and misled anything reading it literally. A related fix: the refresh-failure counter no longer climbs forever on a deployment with no database configured, because "not configured" is a steady, intentional state already reported continuously by the policy-source gauge.

Risk scores are computed by the platform again

(Community and Enterprise)

The database-backed engine's condition resolver never had a computed risk score to read, so a bare risk_score condition silently fell back to reading the value straight out of the caller's own request body. Two seeded, enabled policies have therefore been unable to fire on a real signal since January.

risk_score is now computed from the request itself on every evaluation: an SQL-injection pattern via the shared scanner contributes 0.9, a word-boundary-anchored sensitive-data keyword match contributes 0.7, and a select * query contributes 0.3. The explicitly namespaced context.risk_score field is unaffected and continues to resolve the caller-supplied value under its own name.

Two deliberate departures from the retired engine's old calculator are worth knowing. The sensitive-data pattern is anchored, so a question like "what is a monkey" or "tell me about tokenization" no longer scores for containing "key" or "token" inside a longer word. And role no longer contributes, because role is an authorization signal rather than a risk signal, and as built it meant the more trusted the caller, the more likely they were to be blocked.

Two seeded policies consequently begin evaluating on a real signal for the first time. One fires above 0.8 with a warn action, the other above 0.6 with an alert action. Neither newly blocks: both are allow-but-annotate. Review both thresholds against the new weights before rolling out, because the weights are not the ones those thresholds were originally tuned against.

Migration core/160 deletes a related row. A 2025 seed created high_risk_block, a near-duplicate of the tuned sys_dyn_high_risk_block under the same name, conditions, priority and tenant. A later migration's downgrade from block to warn tuned only the tuned twin and never touched the duplicate. With risk_score now computed, the never-tuned duplicate would have started blocking production traffic on upgrade while its intentionally-tuned twin sat at warn right beside it. The duplicate is the row deleted; a paired down-migration restores it verbatim.

The audit and usage APIs stop reporting what they did not measure

(Community and Enterprise)

avg_latency_ms is nullable. POST /api/v1/audit/report and POST /api/v1/audit/summary used to coalesce the average to 0 and return a non-nullable float, so a window with nothing measured reported a confident 0ms. Both now return an explicit JSON null when there is nothing to average, alongside a new latency_sample_count. On the report the divisor also changed from every verdict row to the measured rows only. The measured predicate relaxed from "greater than zero" to "not null", so a genuine sub-millisecond decision is now a sample rather than a discarded row. A client binding avg_latency_ms to a non-optional float will break.

tokens_used, cost and response_time_ms are omitted rather than sent as 0. On POST /api/v1/audit/search, POST /api/v1/audit/export (both JSON and CSV) and GET /api/v1/audit/{id}, these three fields were read from nullable columns without checking validity, so a row that recorded nothing was published as a measured zero. The key is now absent on a row with nothing recorded and present with its real value otherwise, and the CSV writes an empty cell rather than 0, which is what stops a spreadsheet average from counting it. A genuine measured 0 still renders as 0. A consumer that indexes these keys unconditionally, or treats a missing key as an error, must handle absence.

POST /api/v1/audit/report returns 500 where it previously returned a partial table. A failure scanning a top-policy row used to be logged and skipped, so the report disclosed "showing top 9 of 12" while holding 8 rows. A scan failure is now fatal to the response, and the aggregation is additionally bounded by a 15 second timeout that fails the same way. The error body states explicitly that no report was produced and that this is not a report that nothing was found. The sibling POST /api/v1/audit/summary deliberately does not fail: it degrades with a new top_policies_unavailable flag instead, because a summary tile going quiet is not the same thing as a compliance report going short.

Usage latency is nullable, and rows that were being dropped now appear. On GET /api/v1/usage and GET /api/v1/usage/summary, avg_latency_ms and the raw per-row latency become nullable, with new sample-count and bucket-count companions. The larger consequence is on the list endpoint: because the scan loop skipped rows it could not decode into a non-nullable integer, and the OpenTelemetry metrics writer never names that column, every metric-sourced row was silently dropped from both the response and the CSV behind a 200 OK. Those rows now appear, so response and export row counts can rise on upgrade. GET /api/v1/audit/session-summary gains the same nullable latency treatment and a per-bucket sample count.

Two supporting fixes make the numbers mean something. The decision, gateway, MCP-tool and OpenAI-compatible audit writers now record their measured latency, threading their own elapsed time from the same clock the decision-duration histogram reads, so a tile and a metric cannot disagree; the human-review writer records an explicit unmeasured marker rather than a literal 0. And the top-policy tables on both surfaces resolve a policy's identity through the shared chain: they previously grouped on a single detail key, so every row whose writer stamps arrays was dropped before grouping and never appeared in the list at all. Both surfaces now report whether what they resolved is a display name or a raw identifier, and a new total_policies field discloses the pre-truncation distinct count so a truncated top ten says what it truncated.

The SEBI audit export reports its own coverage

(Enterprise)

The SEBI export read columns its tables do not have, swallowed the failure, and printed a confident complete artifact over it. A score of 100.00 computed over sections the query could not read is an affirmative regulatory claim manufactured out of a read that never happened. Every affirmative claim the document makes is now one the export can support, and where it cannot, the document says so.

Two SEBI surfaces exist and they behave differently. Be precise about which one your integration uses.

POST /api/v1/sebi/audit/export (the legacy, synchronous route) is the route that gains partial. The roll-up is conditional, not universal, and it has three outcomes, not two:

  • partial means some but not every requested section could be served, or a served one carries a scope gap. summary.compliance_score is then absent rather than null or 0.
  • failed means no requested section could be produced. A request whose sections all failed is failed, not partial.
  • completed, with a score, is still the outcome for a subset request that hits neither a failure nor a scope gap. That case is the documented example in the public API specification and is unchanged.

An all-types request reports partial on a stock deployment, because the human-oversight and PII-redaction sections read stores that no migration in the platform creates, so both report as unavailable while the other three serve. A request for only those two sections returns failed. An integration must therefore handle all three values rather than treating partial as the only new one, and must move off status === "completed" and off a non-optional compliance_score to reading the new summary.report_state and summary.sections fields.

POST /api/v1/compliance/reports (the asynchronous facade) never returns partial. Its status vocabulary is exactly pending, processing, completed and failed, and this release does not widen it. On an Enterprise build the portal always takes this route, so on a real stack every SEBI pack lands as completed, including the incomplete ones.

If you poll the facade, do not wait for a status change. Completeness on that route is carried inside the document, as a section titled "Report completeness" whose summary opens with a complete-or-not pair and which names the affected sections and their verbatim causes. The presence of that section is the signal, and so is its absence: it is prepended only when at least one section failed or carries a scope gap, so a complete pack does not contain it at all. Test for the section, not for a sentence. The job record itself carries no completeness signal; giving the facade a first-class completeness field is a cross-regulator change tracked separately.

For a portal operator, the Compliance page is now the primary way to learn a pack is incomplete. Where a finished SEBI report used to say "Report ready." full stop, an affirmative statement of completeness the code could not support, the sentence is now qualified rather than removed: it still opens "Report ready." and then points at the part of the document that can answer the question. It is a blanket caveat rather than a data-driven one, because the job record carries no completeness signal to drive it from, and it is deliberately scoped to SEBI, since the completeness block is built by the SEBI provider alone. A programmatic facade caller never sees this panel, and for them the only in-band signal remains the document itself.

Four further affirmative claims the export could not support are corrected:

  • A section is disclosed as incomplete whether or not it returned rows. A scope gap used to be reported only where a section reads a tenancy dimension one earlier fix happened to check, which left two sections silent under the production-default portal shape. The rendered artifact now carries a scope-gap note on a populated section too, which is the more dangerous of the two cases, because a section with rows reads as the complete record.
  • The section queries read the columns their tables actually have. The LLM-calls section selected fourteen columns, eleven of which never existed on that table, and a substring classifier read the missing-column error as a missing table, so the section returned clean, empty and apparently successful: an artifact reporting "no LLM calls in this period" when it had failed to look. Missing-relation errors are now classified by SQL state rather than by substring, so schema drift is reported as an operator-visible fault instead of as a capability the deployment lacks.
  • Retention rows no longer report COMPLIANT over a configuration nobody set. The retention endpoint mapped a missing configuration row to the platform's 1825-day default and a success, so every data type reported compliant. That table ships empty on a stock migration-applied database, and two of the five SEBI keys can never have a row at all, so this was every deployment. Those rows now report an unknown status with a not_configured cause, and a new retention_configured boolean travels with the 1825 figure so a reader can tell a configured period from a compiled-in one.
  • The readiness pack no longer asserts that PAN and Aadhaar detection is enabled on the strength of a query against a table that does not exist. SEBI's pack is specifically about PAN and Aadhaar handling, so this was the single most load-bearing sentence in the document. The check now reports that it could not verify, in wording that is explicitly neither a claim that detection is off nor a claim that it is on, and it fails closed on the overall readiness verdict, because a control nobody could inspect must not count toward readiness.

A CSV export also no longer reports on sections the caller did not request. The rendered artifact built all six sections regardless of the requested data types, so a single-section request returned a document whose unrequested sections each stated affirmatively that a search had run and found nothing, over queries that were never issued. A requested section that could not be produced is still rendered, with its cause.

Finally, summing the section counts used to report more records than exist, because a governed LLM call is genuinely a member of both the LLM-calls and decision-chain sections. The overlap is now computed by intersecting row identifiers, subtracted from the total, and disclosed in the artifact rather than in a source comment.

Execution reads and workflow identifiers

(Community and Enterprise)

An unscoped execution list is now 401. A /api/v1/unified/executions list carrying neither a tenant nor an organization key used to fall through to an unscoped read of every organization's executions and return 200. It now returns 401 with an identity-required error, matching what the by-id half of the same handler already returned for the same input.

An organization-wide list with no bypass-RLS admin pool is now 500. The portal's organization-wide read is refused when the application database role is in use and no admin pool was installed, because that read would otherwise be filtered to zero rows by row-level security and restore exactly the confident-empty page this change exists to remove. The remedy is to set AXONFLOW_DB_PLATFORM_ADMIN_URL. In practice a missing value is met earlier than this route: the orchestrator carries a fatal boot guard on it, and refuses to start when the app-role posture is on and the variable is blank, so that combination crash-loops rather than serving anything at all. The guard predates v10.0.0 and this release does not change it. That makes the 500 defence in depth rather than the arm a deployment is likely to meet first. It is genuine handler behavior and it is what the route does; it is simply unreachable in any configuration that boots, and it stands ready should the boot guard ever be relaxed. Two further refusal arms guard an unnormalized organization key and a request asserting organization-wide and tenant-scoped reads at once.

The organization-wide branch is reached only over a new trusted-hop header, X-Axonflow-Tenancy-Scope, which is honoured only over a valid internal-service proxy-authentication token, is listed in the closed set of headers a client may never assert, and which the agent strips from every inbound client request. Its absence means "narrow by the tenant header as before", so every existing caller keeps its current per-credential narrowing untouched. Cancel deliberately does not honour the assertion and keeps the strict tenant-ownership check.

The underlying defect was a category error: execution history stamps the tenant from the executing caller's credential, while a portal session has no credential identity at all and is handed an arbitrary tenant of its organization as a display default. Comparing those two rendered a confident zero over data the session was fully authorized to see. The portal Workflows run tile and Executions page now count the organization's executions.

Declarative workflow run ids change prefix from wf_ to wfe_. Ids returned by POST /api/v1/workflows/execute change prefix, because that route runs a spec handed to it in the request body and appears in the governed workflow tables nowhere at all, so it is a different thing from a governed control-plane workflow and no longer shares its prefix. The ids are opaque and nothing in the platform parses them, but a caller that stored one and matches on the wf_ prefix stops matching. Ids minted before this release keep their old prefix; no migration runs and no row is rewritten.

Three related identifier fixes ship with it. A governed control-plane workflow id is no longer 32 bits: it was a UUID truncated to its first eight hex characters, in two places, and truncating a UUID does not preserve its collision properties. Both sites now mint a full UUID. Be precise about what the old collision did: a plain insert with no conflict clause meant two live runs could not silently merge, and the second create failed on the primary key. The silent merge was across time, because the workflow id is stamped into audit detail whose retention outlives both the deletable workflow row and the execution-history cleanup, so a reused id re-points historical audit correlation at a different run. The unified execution record for a workflow run now carries the workflow's own id instead of minting a second one, so the Approvals queue and the Executions page stop showing an operator two different ids for the same run; no migration runs, and rows written before this keep their minted ids and stay addressable. The multi-agent plane deliberately keeps two ids, because a plan is a proposal with a lifetime, a version history and a confirm step, so a plan and a run of it really are two things. And a guessable clock-derived execution id can no longer be minted: a dead fallback that produced a Unix-nanosecond string, and duplicate ids within a single run when forced, is deleted.

The operator console

(Enterprise)

Five portal surfaces stop stating something the system did not mean.

The Compliance Summary renders all five verdict buckets. The backend split deferred and errored requests out of the allowed bucket precisely so they would stop being miscounted; the display never followed, which turned a miscount into an omission, and the visible tiles simply did not add up to the total with nothing on screen naming the difference. All five now render, and the caption states the invariant so the arithmetic is checkable by the reader rather than merely correct in the payload.

The Policies page consumes the server's category registry instead of a hardcoded four-entry list keyed on spellings the server does not emit. Every lookup missed and fell through to a title-caser, which was the default path rather than an edge case, and is why one category could not be selected at all. The registry is now bound to shared constants at compile time, widened, and unioned with the categories a tenant's rows actually carry, so a curated list lagging the data is structurally survivable. A registry failure is now disclosed on the view an operator actually lands on, rather than behind a filter the default view does not select.

The Policies page no longer presents a stored action as the operative one where a detection-posture lever displaces it. The page already modelled effective versus stored for overrides; it now covers the other displacement mechanism, using the vocabulary the engine already emits, via a new posture_lever field on a unified policy. An empty value means no lever governs the category, which is deliberately not a claim that the stored action column is what runs. Verdicts are unchanged.

The Approvals page renders an approval's compliance framework and its EU AI Act article as two labelled facts. Concatenated, a financial-crime approval read as though the anti-money-laundering obligation arose from the EU AI Act. It does not: that article attaches to the human-oversight queue mechanism, not to the policy that triggered the review. The sub-line asserting it is now conditional on the value actually naming that article, matched strictly.

The audit page pins a locale on both the filter inputs and the rendered timestamps, and names the timezone once. The date half of a rendered timestamp used the JavaScript runtime's default locale while the start and end controls beside it are native date inputs using the browser locale, so the same numeric date meant two different days depending on which half of the screen it came from. The audit and approvals tables also no longer clip cells that do not fit with no affordance to read the whole value.

The audit and usage latency tiles report absence rather than a fabricated 0ms. They now render N/A when there is nothing to average, <1ms for a genuine sub-millisecond mean, and a dash for an unmeasured row, and each tile carries the basis it was computed over, so an average over a small self-selected subset of the rows on screen no longer reads exactly like an average over all of them.

Security

An unscoped cross-organization execution read is refused. A /api/v1/unified/executions list carrying neither a tenant nor an organization key ran an unbounded read across every organization and answered 200. It is now 401, and the repository refuses the same shape independently rather than relying on the handler to have done it.

Policy is selected by organization rather than by a caller-chosen tenant. tenant_id arrived on the request and was never verified against anything, so it was a client-supplied string that narrowed which policies were evaluated - a caller able to name a tenant could be evaluated against that tenant's rules rather than its organization's. Selection is keyed on the organization now. The security direction is a tightening, and the operational consequence is the widening described under Am I affected?: a rule scoped to a subset of an organization's tenants begins governing all of them. A rule that genuinely belongs to a subset of your people belongs on a governance segment (ADR-060), whose membership resolves from your directory rather than from a header.

One override shape moves from never-applied to always-applied, and that direction loosens. An override authored with no tenant was inert on the enforcement path, because the organization leg of the selection compared a column the caller never populated. It is keyed on the organization now and it applies - which is the correct reading of a row whose author left the tenant unset, but it is a loosening, because an override downgrades or disables a policy. ./preflight.sh check 23 counts these rows so they can be reviewed before the image is pulled rather than after. Tenant-scoped overrides are deliberately not made organization-wide, and they need no action for this upgrade.

Segment-scoped policies were silently unenforced on eight enforcement planes. On the gateway pre-check, the workflow step-gate, the MCP-server tools, the four MCP REST routes and POST /api/v1/decide, a segment-scoped policy that blocked a member elsewhere was filtered out before evaluation and the request passed. All eight now resolve segments through the one shared, fail-closed resolver.

Deliberate disclosure: segment coverage is now wide, but it is neither complete nor unconditional. These are two separate gaps with two different remedies, and closing only the first leaves the more reachable one open.

Two enforcement routes still evaluate organization-only, and they are not equally exposed. Both pass no resolved segment set into evaluation, so every segment-scoped policy row is excluded before matching and does not restrict a member who reaches your platform through either. Their scopes differ, and flattening them would overstate one and understate the other:

  • The OpenAI-compatible endpoint, /v1/chat/completions, is the wider of the two. It passes no segment set into the general request-phase evaluation, so a segment-scoped policy of any category is excluded there. The endpoint mirrors OpenAI's wire shape and carries no user-token field, so a caller on it has no validated per-user principal from which segments could be resolved; its identity is a synthetic per-credential one.
  • The response plane, POST /api/v1/process, is the narrower one, and it is an enforcement gate rather than an observability read. A policy whose effective response-phase action is block withholds the LLM response there and the caller is answered 403, so what is excluded is genuinely unenforced rather than merely unreported. It serves proxy, Gateway and multi-agent modes, not Gateway Mode alone. Its exposure is narrower because that plane evaluates only the PII and sensitive-data categories your detection configuration has enabled, and skips evaluation entirely when none are. What goes unenforced is therefore a segment-scoped policy in one of those enabled categories whose effective response-phase action is block.

The REST MCP handlers and POST /api/v1/decide, both of which belonged on this list before this release, now resolve segments and no longer do.

On six of the eight planes, enforcement is conditional on identity. A caller presenting no validated per-user token is still evaluated organization-only and is not refused for it. That is a deliberate compatibility choice rather than an oversight, and it has a consequence worth stating without softening: a member can shed a segment-scoped restriction by simply not sending a token. Turn on the require_user_token posture described under Per-user identity can be made mandatory to close that, per organization or deployment-wide. The posture's gate points are the MCP-server session-authentication plane, the four MCP REST routes and POST /api/v1/decide, and those six planes are exactly the ones it closes.

The other two planes sit outside the posture, in opposite directions, and neither is covered by turning it on. The gateway pre-check already requires a validated per-user token unconditionally on an enterprise deployment: a caller presenting none, or one whose token fails validation, is answered 401 before any policy is evaluated, so the posture has nothing to add. The workflow control plane's HTTP step-gate is the opposite case. It reads X-User-Email, degrades to organization-only when the header is absent, and has no posture remedy today, which is the conditionality already disclosed above under the step-gate. On that plane the paired organization-scoped policy is the whole of the answer.

This is published rather than left to be discovered, because someone who believes a segment-scoped policy covers every plane unconditionally is worse off than someone who knows where it does not. If you rely on segment scoping as a security boundary, do three things. Turn the posture on, understanding that it reaches six of the eight planes and not the step-gate. Gate the OpenAI-compatible endpoint at the edge until the tracked work lands. And keep an organization-scoped policy behind any segment-scoped one, so the organization-only evaluation still refuses on both remaining routes.

Be exact about why that third step is the one that reaches the response plane, because the reason is not that the plane is unreachable from outside. POST /api/v1/process is registered on the agent's own public router, so it is separately addressable. But gating that one path would not close the plane: POST /api/request, a second public agent route, forwards to the same orchestrator handler, so both public paths land on the same response processing. A path-based edge gate is therefore incomplete rather than impossible, and an operator who gated only the obvious path would believe the plane covered while the other route still reached it. That is why the paired organization-scoped policy, which binds to the evaluation rather than to a path, is the remedy that actually covers this plane.

The MCP-server plane no longer accepts a shared credential where a per-user identity is required. A caller with no validated per-user token, or holding a token that names a shared synthetic identity, is refused on check_policy and check_output when the organization holds an enabled segment-scoped policy for that phase, rather than being evaluated as though no segment restriction existed. It is refused on a second trigger too, which an operator sizing blast radius needs: a policy set that could not be read at all also denies, because whether the verdict depends on segments is then unknown. A new metric, axonflow_agent_mcp_segment_identity_unresolved_total, counts exactly that refusal per tool, deliberately separate from the existing fail-closed resolver counter, because the two have different operator remedies: provision per-user tokens, versus repair the identity store. The counter is compiled into both editions, but in a Community build the refusal it counts is unreachable and the series never acquires a label value.

A presented-but-invalid per-user token is no longer downgraded to a synthetic service identity. On the four MCP REST routes, a token that failed validation fell through to the same synthetic identity a caller who sent nothing received, so a rejected credential was answered with service rather than with a refusal. It is now audited as user_token_rejected and refused with 401. The consequence to plan around is revocation: revoking a per-user token now actually revokes it on that plane. Any caller still working today on an expired, malformed or revoked token stops working on upgrade, which is the intended outcome and is why this is called out rather than left to be found.

Identity suppression is closable at authentication. Enforcement that depends on the caller presenting a strong identity lets the caller choose a weaker one, and on these planes a token-less enterprise caller was given a synthetic shared identity rather than being turned away. The new require_user_token posture removes that choice for an organization that opts in, and it fails closed: where the posture itself cannot be read, the answer is "required". See Per-user identity can be made mandatory for the gate points and the resolution order.

Gateway Mode LLM call records now carry the organization that made the call. The organization column has existed on that table since an earlier migration, and the writers disagreed about it in two different ways: the Gateway Mode writer omitted the column from its insert entirely, so every Gateway Mode row landed with a null organization and could be scoped only by the client credential, while the OpenAI-compatible writer bound a bare string, so a request with no resolved organization planted an empty string, which is a value no organization predicate can subsequently claim. All three writers now stamp the organization from the authenticated identity and route a blank organization through one helper so it is written as SQL null rather than as an empty string. This can lengthen how long those rows are kept, and only lengthen it: the retention sweep buckets by organization, a null-organization row falls into the default window, and per-organization overrides are clamped upward to the regulatory floor. An operator with no overrides configured sees no change in timing. Nothing is backfilled, so pre-release rows keep whatever their writer stored.

Community edition

This release carries substantial community changes. The single-engine consolidation, the removal of AXONFLOW_DEBUG_POLICIES and the plane="memory" label, the four new policy-engine health metrics, the computed risk score and the two seeded policies it reactivates, the deterministic evaluation order and the risk-score clamp, the health-check fix, the audit read-API changes on report, summary, search, export and single-entry reads, the workflow identifier work, and the execution-read refusals are all core code and ship in both editions from the same source. All eight migrations ship to every edition.

The segment-enforcement refusals compile into both editions but are unreachable on a Community build, which has no identity-attribute resolver, so a Community deployment sees no behavior change on any of those planes and never sees the new refusal metric acquire a label value. The require_user_token posture and the rejected-token refusal are core code too, and core/163 adds its column on every edition, but every gate point keys on an enterprise-authenticated caller, so neither is reachable on a Community build and a Community deployment sees no change from either. The SEBI export, the compliance report facade, the multi-tenant SAML service and the operator console are Enterprise surfaces, so that work applies to Enterprise deployments only.

Community example modules move to the published Go SDK v9.1.1 across 61 example modules, completing the previous train's client releases.

Before you upgrade

  • Eight migrations run this release: core/160 through core/167. All eight ship to every edition and apply with the usual boot-time runner. From a v9.19.0 baseline the deploy delta is images plus these eight. core/161 and core/162 need sizing; core/163 and core/164 are cheap. core/165, core/166 and core/167 each hold ACCESS EXCLUSIVE until commit, so reads are blocked on the tables they touch, and core/167 blocks the portal Approvals page while its two indexes build. core/165 needs a decision rather than sizing - see the bullet below. Full detail, including the sizing SQL, is in the v9 to v10 migration guide.
  • Size your statement_timeout against your own audit_logs row count first. core/161 and core/162 are whole-table backfills, each one statement in one transaction, and a timeout too tight for them is a boot loop, not a skipped step, because the migration runner answers a migration error with a fatal exit. Each costs one sequential scan of the whole table plus one new row version per matched row, plus the bloat those versions leave until autovacuum catches up. Readers are not blocked. The migration guide carries read-only queries that measure both the scan size and each migration's match count on your own data; run them before upgrading. If audit_logs is large, take an actual timing on a restored copy rather than extrapolating.
  • Run ./preflight.sh and read checks 23 and 24 before you pull the image. They are the only pre-upgrade view of the tenancy change. Check 23 names every organization whose policy rows or overrides are scoped per tenant, and every organization whose rows cover only some of its tenants; those rows begin applying organization-wide. Check 24 names every policy row that core/165 will make unselectable, which is the one change here that stops a rule firing. Run the preflight as axonflow_platform_admin, or as the database user that owns the tables on a docker-compose bundle. Under a role with neither ownership nor BYPASSRLS the policy tables are filtered to zero rows by row-level security and the counts are meaningless; the script detects that and says so rather than reporting an all-clear, but the right role is the answer.
  • Do not re-run core/161 by hand after upgrading. Its predicate selects a zero response time, which is precisely the shape the new writers produce for a genuine sub-millisecond decision. Running it again after the new writers are live would erase real measurements. Both down-migrations for 161 and 162 are deliberate no-ops, because a fabricated zero is not recoverable from a null.
  • Set AXONFLOW_DB_PLATFORM_ADMIN_URL before upgrading if you run the customer portal on the application database role, which is the default. Without it the portal's organization-wide Executions read returns 500 instead of a confident empty page. The stronger reason to set it is older than this release: with the app-role posture on and the variable blank, the orchestrator refuses to boot and crash-loops, so the variable is a precondition for starting at all rather than only for that one route.
  • Decide whether to require per-user tokens, and set the variable before you turn the posture on. require_user_token defaults to off per organization and AXONFLOW_REQUIRE_USER_TOKEN defaults to false, so upgrading changes nothing on its own. Turning it on refuses token-less enterprise callers at authentication, so provision tokens for every such caller first. Set the variable only to a value the platform recognizes, because on an agent wired to a database an unparseable one refuses the boot rather than guessing. On an agent with no database that guard does not run, so check the spelling yourself rather than reading a clean boot there as confirmation.
  • Expect revoked, expired and malformed per-user tokens to stop working on the four MCP REST routes. They used to be downgraded to a synthetic service identity and served. Audit which of your callers are relying on that before upgrading rather than after.
  • Confirm the portal can read the SAML SP keypair secret, or provision per-tenant keypairs, before upgrading. The fabricated per-restart keypair the portal used to mint on a failed secret load is gone, so a tenant with no sp_private_key of its own is refused when its SAML configuration loads. Read the portal boot log, and list the tenants that store no keypair of their own, before you upgrade rather than at the first failed login; the migration guide carries the read-only query for the second half. POST /api/v1/sso/config/test reports the refusal per tenant on v10.0.0, but it takes no tenant parameter and tests only the calling session's own configuration, so on a tenant-isolated deployment it is not a sweep an operator can run across tenants, and it needs the sso:configure permission rather than merely an admin session. Neither check parses a stored PEM, so a malformed one reports unaffected and is refused at login.
  • Remove AXONFLOW_DEBUG_POLICIES from your deployment configuration. It no longer exists and setting it does nothing.
  • Sweep your dashboards and alerts for plane="memory". Any panel, recording rule or alert matching axonflow_policy_condition_unevaluable_total{plane="memory"} goes permanently empty rather than erroring, so nothing will tell you. The remaining label values are unchanged.
  • Review the two seeded risk-score thresholds against the new weights. The policies at 0.8 and 0.6 begin evaluating on a real signal for the first time since January. Neither blocks, but both start annotating traffic that previously passed unconditionally.
  • Update integrations that read the audit, usage or session-summary APIs for nullable latency, for omitted token, cost and response-time keys, and for the report endpoint's new 500. A genuine measured 0 still renders as 0; what changed is that an unmeasured value is now absent rather than fabricated. Expect usage response and export row counts to rise, because rows that were being silently dropped now appear.
  • Update integrations on the legacy SEBI export off status === "completed" and off a non-optional compliance_score, and plan for partial and failed as normal terminal states on that route. If you poll the compliance-reports facade instead, there is no status change to react to: read the document's "Report completeness" section, whose very presence means the pack is incomplete, or the portal's completeness caveat.
  • Expect an all-types SEBI pack to be incomplete on a stock deployment, because two of its sections read stores no migration in the platform creates.
  • From older versions: v10.0.0 is cumulative. If you are coming from v9.16.x or earlier, read the intermediate release notes; the v9.19.0 notes carry the policy-condition convergence and the empty-conditions exclusion that run when you cross that version, and the v9 to v10 migration guide enumerates every migration from core/155 through core/166 for a multi-release skip.

SDK and plugin compatibility

No SDK or plugin release accompanies this train. Minimum-version floors are unchanged, so clients below the recommended versions keep working. One advertised recommendation moves without a release behind it: the platform's recommended Claude Desktop plugin version was a stale 0.3.1 and is corrected to 0.3.2, a version published several trains ago, so this is a catch-up in what the platform reports rather than a new plugin release. Everything else changes on the platform side: every SDK forwards the response shapes described above verbatim, so an application binding a now-nullable or now-absent field to a non-optional type must be updated regardless of which client it uses.

ClientRecommendedWhat changed
Python SDK9.1.0No client release this train. Handle nullable latency and absent token, cost and response-time fields in application code.
TypeScript SDK9.1.0No client release this train. Same application-side handling as above.
Go SDK9.1.1No client release this train. Same application-side handling as above.
Java SDK9.1.0No client release this train. Same application-side handling as above.
Rust SDK0.8.2 (preview)No client release this train. Same application-side handling as above.
Claude Code plugin1.11.0No plugin release this train.
Cursor plugin1.7.0No plugin release this train.
Codex plugin1.7.0No plugin release this train.
OpenClaw plugin2.8.6No plugin release this train.
Claude Desktop plugin0.3.2No plugin release this train. The platform's advertised recommendation catches up from a stale 0.3.1 to the long-published 0.3.2.

The Python, TypeScript, Go, Java and Rust SDKs are all unchanged this release, as are all five plugins. A caller using the MCP-server check_policy or check_output tools through any of them must supply X-User-Token where the organization holds an enabled segment-scoped policy for that phase.