v10.0.0 - segment enforcement reaches five more planes
[10.0.0] - 2026-08-26 (segment enforcement reaches five more planes; identity suppression closed at authentication; one dynamic-policy engine; audit and compliance surfaces stop fabricating values)
Scope: every operator upgrading from 9.19.0. This release removes two boot
fallbacks and one request-time identity fallback, adds new refusals on the
segment-enforcement, authentication, execution-read and audit-report paths,
ships eight migrations (core/160throughcore/167), and changes what
several read APIs put on the wire when they have nothing to report. FIVE
consequences need a decision before you upgrade, not after:
On the four MCP REST routes, an enterprise caller presenting a per-user
token that FAILS to validate is now401instead of being served under a
synthetic service identity. Revocation, expiry and key rotation begin
taking effect on those routes for the first time. Nothing is opt-in about
this one. Inventory the tokens your MCP callers present before upgrading.Migrations
core/161andcore/162rewriteaudit_logswhole-table, and
astatement_timeouttoo 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 ownaudit_logsrow count
before upgrading.The legacy
POST /api/v1/sebi/audit/exportgains apartialstatus and
omitscompliance_scorewhen SOME but not all requested sections could be
served, or a served one carries a scope gap; an all-types request hits that
on a stock deployment. If NO requested section could be produced it is
failed, notpartial(Enterprise). The
POST /api/v1/compliance/reportsfacade's status vocabulary is UNCHANGED
and never returnspartial; on that route completeness is visible only
inside the document and in the portal's completeness caveat.A deployment running
axonflow_app_rolewith no
AXONFLOW_DB_PLATFORM_ADMIN_URLdoes not boot, and has not since
before 9.19.0. The orchestrator refuses at startup rather than serving the
portal Executions list. If you are upgrading from 9.19.0 you are already
past this, by construction; set the variable before upgrading only if you
are also turning the app role on.Human-in-the-Loop approvals become Enterprise-only.
Evaluationwas
entitled and is not any more. An Evaluation deployment using WCP
step-gate approvals has them today and will lose them: the step is still
HELD, but no reviewer entry is created. Entries you already hold are not
lost - they stay listable, approvable and rejectable on Evaluation, and
the expiry sweeper still runs, so nothing is stranded. What stops is the
creation of NEW ones. If you run Evaluation and rely on workflow
approvals, this is a capability removal, not a refinement - inventory your
queue before upgrading (query in the Breaking changes section).Read the Breaking changes and Migration sections in full.
Breaking changes at a glance
- Per-tenant policy targeting is dropped: policy is selected by
organisation, not bytenant_id(#3490).tenant_idis the username half
of the Basic-auth credential, assigned from whatever the client typed and
validated by nothing, so the set of policies governing a caller was a set
the caller chose. Selection now keys on the organisation id from the signed
licence. For one tenant per organisation this is a no-op; where one
organisation owns several tenants with different rows, those rows start
applying organisation-wide../preflight.shchecks 23 and 24 name the
affected rows before you upgrade, including the case that is easiest to miss- an organisation whose one policy row is scoped to one of its several
tenants, which carries no divergence among its own rows and still starts
governing every sibling. Read the Security section: the removed boundary was
never enforceable. Overrides are deliberately NOT made organisation-wide,
because that is the one direction of this change that would loosen
enforcement.
(Community / Enterprise)
- an organisation whose one policy row is scoped to one of its several
- A policy row with no resolvable organisation stops being selectable
(#3490, migrationcore/165).org_idbecomesNOT NULLand non-empty on
static_policies,dynamic_policiesandpolicy_overrides. The migration
resolves what it can and stamps whatever is left with__axonflow_unowned__,
a sentinel the platform refuses on both sides of every comparison, so a
stamped row is selectable by nobody. This is the one change in the release
that REMOVES enforcement rather than widening it. The migration names the
rows in the agent's boot log, which is during the upgrade;./preflight.sh
check 24 names the same rows read-only beforehand.
(Community / Enterprise) - Seven orchestrator policy-authoring routes answer
401 ORG_REQUIRED
without anX-Org-IDheader (#3490).POST /api/v1/templates/{id}/apply
(which answered201),POST /api/v1/policies/import,
POST /api/v1/dynamic-policies/import,POST /api/v1/policies/simulate,
/api/v1/policies/impact-report,/api/v1/policies/conflictsand the
dynamic-policy list. Each of them authors or evaluates a policy, and a policy
is now selected by its organisation. Refused rather than defaulted to the
tenant: stamping the row with the Basic-auth username is precisely what
this release removes, so on any deployment whose licence organisation differs
from that string the policy would be created, return201, list correctly
and govern nobody. These routes are NOT proxied by the agent, so no gateway
sets the header for you; an operator calling the orchestrator directly must
send it.
(Community / Enterprise) - The legacy
organization_idcolumn is dropped from the policy tables
(#3334, migrationcore/166).static_policies,dynamic_policiesand
policy_overrideslose it. It was the pre-v9 organisation key, superseded by
org_id, andcore/165resolves the last rows that depended on it before it
goes. Anything reading that column directly in SQL against these three tables
breaks; nothing on the wire changes.
(Community / Enterprise) - Governance segments are enforced on five more planes, and an unresolvable
segment set now DENIES where it previously passed: the gateway pre-check
(#3312), the WCP HTTP step-gate (#3281), the MCP-servercheck_policy/
check_outputtools (#3430), the MCP REST plane's four routes (#3447), and
POST /api/v1/decide(#3456). On the last two, enforcement is conditional
on the caller presenting a per-user token unless the organisation sets
require_user_token. The WCP step-gate is conditional too, and
require_user_tokenhas no gate point there; read the Security section
before relying on any of the three.
(Enterprise) X-User-Tokenbecomes required on the MCP-servercheck_policy/
check_outputtools for any organisation holding an enabled segment-scoped
policy for that phase (#3430). (Enterprise)- A per-user token that FAILS to validate is now
401on the four MCP REST
routes instead of being silently downgraded to a synthetic service identity
and served (#3472). This is a removed fallback and it is not opt-in:
revocation, expiry, algorithm pinning and signature checks begin taking
effect on those routes. (Enterprise) require_user_tokenlets an organisation refuse a token-less enterprise
caller at authentication, on six gate points, with migrationcore/163
andAXONFLOW_REQUIRE_USER_TOKEN(#3476). Opt-in, default off; an
unparseable env value is a BOOT FATAL. (Enterprise)axonflow_segment_resolution_totalgains aphaselabel (#3473), so
every existing series of that counter is replaced by phase-split ones and an
exact-series dashboard selector goes empty. (Enterprise)POST /api/v1/mcp/check-inputidempotency entries are scoped to the
calling principal (#3447), so theidempotency_keys.endpointvalue changes
shape and keys cached before the upgrade no longer replay. (Enterprise)- Audit attribution on
POST /api/v1/mcp/check-inputand
POST /api/v1/mcp/check-outputnow names the validated per-user identity, not
a trustedX-User-Email(#3447), so rows for token-bearing callers under
AXONFLOW_TRUST_IDENTITY_HEADERSwill name a different email than they did
before. (Enterprise) - The SAML SP signing keypair is no longer fabricated when Secrets Manager
cannot be read; affected tenants are refused at configuration-load time
instead (#3341). (Enterprise) - Two previously-
200execution-read shapes now fail (#3367). A
/api/v1/unified/executionslist carrying neither a tenant nor an org key is
401; a portal org-wide list underaxonflow_app_rolewith no BYPASSRLS
admin pool is500. The second is a backstop rather than something you will
meet on upgrade: that configuration has been refused at BOOT since before
9.19.0. (Community / Enterprise) - The in-memory
DynamicPolicyEngineis deleted and boot no longer falls back
to it (#3319).AXONFLOW_DEBUG_POLICIESis removed entirely, and
axonflow_policy_condition_unevaluable_totalno longer emits
plane="memory". (Community) avg_latency_msis now nullable onPOST /api/v1/audit/reportand
POST /api/v1/audit/summary, and onGET /api/v1/audit/session-summary
(#3424, #3436). (Community / Enterprise)tokens_used,costandresponse_time_msare OMITTED rather than sent as
0onPOST /api/v1/audit/search,POST /api/v1/audit/exportand
GET /api/v1/audit/{id}, and the CSV export writes an empty cell (#3424,
#3427). (Community)POST /api/v1/audit/reportreturns500where it previously returned a
silently short top-policies table (#3426). (Community)- The legacy
POST /api/v1/sebi/audit/exportgainsstatus: "partial"and
omitssummary.compliance_scorewhen SOME but not all requested sections
could be served, or a served one carries a scope gap, which an all-types
request hits on a stock deployment (#3435). A subset request that hits
neither still returnscompletedwith a score; a request whose sections ALL
failed isfailed, notpartial. ThePOST /api/v1/compliance/reports
facade's status vocabulary is unchanged and never returnspartial.
(Enterprise) - The declarative workflow engine's run ids change prefix from
wf_to
wfe_onPOST /api/v1/workflows/execute(#3442). (Community) - Two seeded dynamic policies begin evaluating on a real signal for the first
time since January, and migrationcore/160deletes the never-tuned
duplicate that would otherwise have started blocking (#3321). (Community) GET /api/v1/usageandGET /api/v1/usage/summaryreturnnulllatency
where they previously returned0, and raw rows that were silently dropped
now appear (#3436). (Enterprise)- Human-in-the-Loop approvals are Enterprise-only. Entitled:
Professional,Enterprise,Enterprise Plus. Refused:Community,
Free,Pro,Premiumand - newly -Evaluation. An Evaluation
deployment using WCP step-gate approvals has them today and will not after
upgrading. The step is still held, withapproval_enqueue: "tier_disabled"
and no reviewer entry. Existing rows are untouched and stay reviewable
(#3408, #3517). (Community) - The Workflow Control Plane no longer writes the approval queue directly,
so a workflow approval is now subject to the licence-tier gate and the
hitl_approval_historytrail every other entry point applies. On an
unlicensed or unentitled deployment that is a new refusal where rows used to
be written (#3517). (Community)
BREAKING CHANGES
-
Human-in-the-Loop approvals are now Enterprise-only (#3408, #3517).
(Community)Evaluationdeployments lose Human-in-the-Loop approvals. The entitled
set becomesProfessional,EnterpriseandEnterprise Plus.Community,
Free,Pro,Premiumand nowEvaluationare refused.Evaluation licensees have workflow step-gate approvals TODAY - partly by
entitlement and partly through the bypass this release closes - and will not
after upgrading. Arequire_approvalstep gate on an Evaluation deployment
still holds the step (admitting it because the tier is unentitled would
be a governance bypass) but creates no reviewer entry, and reports
approval_enqueue: "tier_disabled"on the wire, on the audit row and on
axonflow_hitl_enqueue_total. The same refusal applies on the agent's
POST /api/v1/hitl/queueand on the policy step-up paths, so the planes
agree.The orchestrator says so at boot:
WCP HITL adapter initialized but DISABLED by licence tier "Evaluation" - require_approval actions will block and report approval_enqueue=tier_disabled, creating no reviewer entryBefore upgrading, find out whether any of your tenants is relying on it.
Run this as the table owner or
axonflow_platform_admin.
hitl_approval_queueis ENABLE ROW LEVEL SECURITY (migcore/025) with
USING (org_id = get_current_org_id()), andget_current_org_id()reads
current_setting('app.current_org_id', TRUE), which is NULL when unset.
org_id = NULLis NULL, so onaxonflow_app_rolewith no GUC this returns
ZERO ROWS AND NO ERROR: an all-clear built from nothing, the same shape as
#3490's preflight check 23. To run it on the app role, set the scope first
withSET LOCAL app.current_org_id = '<your-org-id>';.SELECT org_id, request_type, status, count(*) FROM hitl_approval_queue GROUP BY org_id, request_type, status ORDER BY count DESC;
Existing rows are untouched: nothing is deleted, and pending entries stay
reviewable and resolvable. What stops is the creation of NEW ones.Not affected:
Professionallicensees.GetTierLimitsmaps Professional
ontoEnterpriseLimits, so it is entitled - a point worth stating because
the pre-existingIsEnterpriseTierpredicate in the licence package excludes
Professional (#3416 item 3), and a fix that reached for that name would have
denied them silently.The per-tenant pending-approval cap is now unreachable by any tier, and
that is deliberate rather than an oversight. Every entitled tier maps onto
EnterpriseLimitswithMaxPendingApprovals: -1(unlimited); every tier
declaring a finite cap is refused by the tier gate first. The machinery is
kept as defence-in-depth for a future finite-cap tier and is guarded by a
test that fails the moment one appears.EvaluationLimits.MaxPendingApprovals
is now inert; it was also the subject of a live divergence (100 in the
community tier table, 25 in both enterprise ones, #3416 item 2) and both are
set to 25 so two dead numbers do not disagree. -
Governance segments are enforced on the gateway pre-check plane, and an
unresolvable segment set denies fail-closed (#3312). (Enterprise)
POST /api/policy/pre-checkpassed a hardcoded empty segment set into the
shared engine, which excludes every segment-scopedstatic_policiesrow, so a
policy that blocked a member on/api/requestwas silently unenforced here
and the query passed. The plane now resolves the caller's segments once,
before static evaluation, through the same shared resolver the agent proxy and
MAP planes already use. When the resolver errors the request is refused:
the response isApproved: false,Policies: ["segment_resolution_failed"],
and a reason opening "segment resolution unavailable" and citing ADR-060 and
#2989. That reason is deliberately NOT quoted here as a literal: this
plane's copy of the string separates its clauses with a character the WCP and
MCP-response planes do not use, so a quotation would send a reader grepping
for text their own plane never emits (see the punctuation census under the
MCP-server item, and #3465). It is an HTTP200carrying a deny verdict, not
an HTTP error, so a client must key onapprovedand on the reserved
segment_resolution_failedidentifier, never on the human-readable reason. A
successful resolution that returns no segments is the ordinary case and
proceeds org-only. No new credential is required on this plane. Community
builds have no identity-attribute resolver at all
(NewIdentityAttributeResolverreturns an Enterprise-only error), so a
Community deployment cannot reach the deny and sees no change. -
Governance segments are enforced on the WCP HTTP step-gate, and an
unresolvable segment set denies fail-closed (#3281). (Enterprise)
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 and a
segment-scoped dynamic policy was unenforced there while the same policy was
enforced on/api/v1/processand MAP. The gate now carries the organisation
and the caller email through to evaluation. A segment-resolution failure
returnsdecision: "block"withpolicy_ids: ["segment_resolution_failed"]
and the reasonsegment resolution unavailable - request denied (fail-closed, ADR-060 #2989 P3b), again as an HTTP200body. The refusal cannot be
cleared by an ADR-044 override: it is checked before the generic
not-allowed handling and carries no applied-policy detail for an override to
match.X-User-Emailis newly read on the step-gate and on both resume
routes; omitting it is not an error and degrades to org-only, and the header
is honoured only behind the existing internal-service proxy-auth gate.
Two in-repo service signatures gainedorgID, emailparameters
(ResumeFromLastCheckpoint,ResumeFromCheckpoint). -
Governance segments are enforced on the MCP-server plane, with two distinct
refusals, andX-User-Tokenbecomes required for some organisations
(#3430). (Enterprise)
Thecheck_policyandcheck_outputMCP-server tools evaluated with an empty
segment set, so every segment-scoped policy was filtered out before evaluation
and the call passed. Both tools now resolve segments before evaluating, and
refuse in two separate cases. Be precise about which one is conditional:segment_resolution_failedis UNCONDITIONAL. A caller presenting a
validated per-user token whose segment resolution fails is denied, whether
or not the organisation holds a single segment-scoped policy. The stable
thing to match on is theblocked_byidentifier
segment_resolution_failed; the human-readable reason opens "segment
resolution unavailable" and then says the request was denied on the request
phase, or the response withheld on the response phase, citing ADR-060 and
#2989.segment_identity_unresolvedIS conditional. A caller with no
validated per-user principal is denied only when the effective policy set
for that org and phase actually holds an enabled segment-scoped row, or
when that policy set could not be read at all. The stable identifier is
segment_identity_unresolved; the reason opens "segment membership
indeterminate for a caller with no validated per-user token", citing
ADR-060 and #3430.
Do not pattern-match these reason strings across planes. The punctuation
separating the two clauses is not consistent between the planes that emit
them, so a grep or a string equality check written against one plane's text
silently misses another's. Of the SEVEN production sites in the "segment
resolution unavailable" family, five separate the clauses with an em dash
(platform/agent/run.go:2200,run.go:3272,
human_actor_segment_gate.go:119,gateway_handlers.go:698,
mcp_identity.go:322) and two with a hyphen (mcp_identity.go:320,
platform/orchestrator/wcp_policy_adapter.go:361). Both
segment_identity_unresolvedsites use a hyphen. Match onblocked_by,
which is a stable identifier, not on the reason text. Normalising the
punctuation is tracked as #3465.Two of those seven differ on purpose and will not be normalised away: the
MCP-server RESPONSE-phase wording says the response was withheld rather than
that a request was denied, becausecheck_outputgoverns content already
produced, and the policy-test preview says what WOULD happen, because it
denies nothing. Within the agent this is now a checked fact rather than a
convention (#3456): a test parses that package's sources and fails any
occurrence of the refusal text that is not byte-identical to one of three
pinned spellings, plus a per-file census so a new plane must reuse the shared
constant instead of respelling it. That check deliberately does not reach the
orchestrator's copy, which is the seventh site and the one that still carries
its own spelling. None of this changes the advice: the identifier is the
interface, the reason text is not.Both refusals are an HTTP
200JSON-RPC result carryingallowed: false,
block_reason,decision_idandblocked_by, and both write a canonical
blockedaudit_logsrow. The consequence for callers: where an
organisation holds a segment-scoped policy,X-User-Token(ADR-043/044 Path A
or Path B) is now mandatory on those two tools.X-User-Emailis explicitly
refused as a substitute even underAXONFLOW_TRUST_IDENTITY_HEADERS, and a
token naming a shared synthetic identity is refused too.resolveMCPServerSegmentsForPolicy
(platform/agent/mcp_identity.go:459-490) proceeds without resolving in two
places, and neither is a posture escape hatch for a token-bearing caller.
The first is no identity-attribute resolver being wired, which is every
Community build (:466-468). The second is the fall-through at the end
(:489), reached only by a caller with NO per-user principal on an org whose
effective policy set holds no in-scope segment-scoped row, which is the
conditional arm described above. Detection posture does NOT buy you out of
the first refusal. ThestaticEvaluationWillRunshort circuit is consumed
only insidesegmentScopedPoliciesInScope
(platform/agent/mcp_identity.go:401-403), which is reached only AFTER the
per-user-principal branch has returned (:470-476), so it narrows the
conditionalsegment_identity_unresolvedarm and nothing else. A caller
presenting a validated per-user token resolves unconditionally and is denied
on a resolver failure even on an Enterprise organisation with detection
disabled and zero segment-scoped policies. If your MCP callers hold
per-user tokens, a resolver outage denies them regardless of posture. -
Governance segments are enforced on the four MCP REST routes and on
POST /api/v1/decide, and an unresolvable segment set denies fail-closed
(#3447, #3456). (Enterprise)
POST /mcp/resources/query,POST /mcp/tools/execute,
POST /api/v1/mcp/check-input,POST /api/v1/mcp/check-outputand
POST /api/v1/decideeach passed an unconditional empty segment set into
policy evaluation, so every segment-scopedstatic_policiesrow was excluded
before evaluation. A verified SCIM member of a governance segment was
therefore restricted on the MCP-server JSON-RPC plane and unrestricted on the
same organisation's REST routes, on the same credential. Changing one URL
evaded the control; no second credential and no privilege change were needed.
All five now resolve the caller's segments once per request, through one
shared gate, and reuse that single resolution for the request phase, the
response phase where the plane has one, and the dynamic relay where the plane
has one. Resolving twice in one request could observe two cache states and
enforce two different sets on one logical call.The refusal. A resolver error for a caller who HAS a principal denies with
403and the reserved guard idsegment_resolution_failed, and writes a
canonical blockedaudit_logsrow. It is deliberately kept off the
503"policy evaluation temporarily unavailable" channel, so a policy-side
deny stays distinguishable from an evaluator outage in both the audit row and
the dashboard. Key on the identifier, never on the reason text: see the
punctuation census under the MCP-server item above.Three cases that are NOT a refusal, and an operator sizing this needs all
three. A caller with no validated per-user token, a caller whose token names
one of the platform's shared synthetic identities, and a verified human with
zero group memberships all resolve to the org-only outcome, unchanged from
ADR-060's baseline: org-tier and system-tier policies still evaluate, only
segment-scoped rows are skipped, and nothing is denied. On these five
planes that means a member can still evade a segment-scoped policy by not
presenting a token unless the organisation turns onrequire_user_token
(below), which is where the refusal for that case deliberately lives. This is
the one behaviour that differs from the MCP-server JSON-RPC plane, which
refuses a principal-less caller outright whenever the phase's policy set can
depend on membership.Resolution is keyed on the validated token's
emailclaim, never on
X-User-Email, even underAXONFLOW_TRUST_IDENTITY_HEADERS. That header is
caller-supplied, so keying segment resolution on it would let the same person
shed their segments by naming a non-member colleague, which is the reported
bypass recreated one level down. The header keeps its attribution and ADR-044
override roles.The dynamic half crosses a service boundary. The MCP planes' dynamic
evaluation runs in the orchestrator, so the agent's one resolved set is
relayed to it:POST /api/v1/mcp/evaluate-policiesgains asegment_ids
array on its request body. The orchestrator deliberately does NOT resolve
segments itself, because the two services hold separate segment caches on
separate TTL clocks and a second independent resolution could produce a split
verdict on one request. That route is on the internal-service plane, reachable
only with the internal proxy-auth credential, and the field passes through
unauthenticated: it is used only to NARROW the policy set, so a forged value
on that plane could suppress a segment-scoped policy but can never widen one
onto a caller. Community builds have no identity-attribute resolver, so a
Community deployment resolves to the org-only outcome everywhere and sees no
change. -
A per-user token that fails to validate is refused rather than downgraded,
on the four MCP REST routes (#3472). (Enterprise)
Those four handlers answered ANYResolveUserfailure in enterprise mode by
synthesizing a service identity and proceeding. Because that one branch
covered both an ABSENT token and a PRESENTED one that failed validation, a
token that was malformed, expired, signed with the wrong algorithm, carrying a
bad signature, or explicitly revoked byjtiwas silently discarded and
the request was served as<client-id>@axonflow.localwithRole: "service".
Revoking a per-user token did not revoke anything on these routes.A token that is PRESENTED and fails to validate is now audited and answered
401. The compatibility branch survives, narrowed to exactly the case it was
written for: an enterprise caller that presented NO token at all, on an
organisation that has not opted in torequire_user_token. An infrastructure
gateway acting as a Policy Enforcement Point, which has no end-user token to
forward, is unaffected.This is not opt-in and there is no flag that restores the old behaviour.
The population it changes is any caller currently presenting a token that does
not validate, which by construction is a caller that believes it is
authenticating and is not.POST /api/v1/decidealready refused this shape
before the release; the four MCP REST routes are what changes.The refusal is audited under the reserved identifier
user_token_rejected,
and is kept strictly distinct fromuser_token_requiredbelow. Never
collapse the two. They have opposite operator remedies:user_token_rejected
means a caller's token is bad, anduser_token_requiredmeans a caller sent
none where the organisation requires one. Query them with JSONB containment
(policy_details->'policy_ids' @> '["user_token_rejected"]'::jsonb), never
withLIKE:_is a single-character wildcard, so'%user_token_required%'
also matches unrelated prose in the same row. -
require_user_token: an organisation can refuse a token-less enterprise
caller at authentication (#3476, migrationcore/163). (Enterprise)
Segment-scoped policy is only meaningful if a caller cannot CHOOSE to arrive
without an identity. With the flag off, a segment-scoped policy is not inert:
a caller who presents a validated token is blocked exactly as authored. It is
evadable: a member who does not want to be in scope can decline to forward
the token and be served under the shared service identity instead. Turning the
flag on for an organisation is what makes that evasion impossible, by moving
the refusal to authentication, before any policy is evaluated.Two levers, and an explicit per-org row wins over the deployment default in
EITHER direction (an organisation can opt OUT of atruedefault just as it
can opt IN over afalseone):organizations.require_user_token,BOOLEAN NOT NULL DEFAULT false,
added by migrationcore/163. No backfill.AXONFLOW_REQUIRE_USER_TOKEN, the deployment-wide default. Accepted values
aretrue/1/yesandfalse/0/no, case-insensitive and
surrounding whitespace ignored. Unset meansfalse.
A set-but-unrecognised
AXONFLOW_REQUIRE_USER_TOKENis a BOOT FATAL, not a
warning.True!,enabledoryes pleaserefuses to start rather than
silently resolving to the default, because both guesses are wrong in a way
that is invisible afterwards: one silently disables a security control, the
other silently denies every token-less caller. A deployment that never sets
the variable is unaffected, and an empty or whitespace-only value is not
fatal. The check runs only where a database is wired.Six gate points, all reached only in enterprise auth mode. Five refuse
with401and an auditeduser_token_required:POST /api/v1/decide,
POST /mcp/resources/query,POST /mcp/tools/execute,
POST /api/v1/mcp/check-inputandPOST /api/v1/mcp/check-output. The sixth
is the MCP-server JSON-RPC plane, which refuses at session authentication with
401and aWWW-Authenticate: Basicchallenge. On that plane the refusal
is NOT audited underuser_token_required.initialize,tools/listand
pingwrite noaudit_logsrow for an authentication failure at all;
tools/calldoes write one, but under the reservedunauthenticatedtenant
sentinel withpolicy_ids: ["unauthenticated"], not under the caller's tenant
and not underuser_token_required, because the row is written before any
caller identity is established. Do not expect auser_token_requiredrow from
this plane.tools/callalso collapses every authentication failure into one
generic error, so a caller cannot probe for the cause; the specific cause is
visible to an operator only in the message theinitializehandshake returns.On the MCP-server plane the condition is "no validated identity was
produced", not "no token was sent". Token resolution returns no identity for
two distinct inputs: no token at all, and a token presented on a deployment
where no per-user-token validator registered. Gating on the empty-token case
alone would leave the second open, so an organisation that had opted in could
still be suppressed by sending any junk string on a misconfigured deployment.Two planes are deliberately NOT gate points, and a third is
uncovered. The gateway pre-check
(#3312) never had the synthetic-identity fallback in the first place, so there
is nothing for this flag to change there.POST /v1/chat/completionsis
excluded because it has no per-user token field on the wire at all: every
other gate point refuses a caller who COULD have presented a token and did
not, which a caller can comply with, whereas a refusal there would be a wall
no caller could comply with. The WCP HTTP step-gate is the third, and it
is not a deliberate exclusion: it has no gate point at all, so a governed
caller that omits its per-user token reaches it with no email and is
evaluated org-only with no refusal.require_user_tokendoes not reach
that plane. See the Security section.Posture changes are cached and are not instantaneous.
AXONFLOW_REQUIRE_USER_TOKEN_TTL_SECONDScontrols how long a resolved per-org
posture is held, default 60 seconds, clamped to[5, 600]. An unparseable or
non-positive value logs a warning and uses the 60 second default rather than
clamping. Expect a column flip to be live everywhere within one TTL window.A posture that cannot be READ resolves to "required", never to "not
required". A database outage, a missing table or column, or a scan error
fails CLOSED and is cached for at most 15 seconds regardless of the configured
TTL. This is the opposite direction from the detection-posture lever it
otherwise resembles, and deliberately so: this is an authentication gate, and
resolving it tofalseunder uncertainty would let a database blip quietly
switch the control off for every request in the window. A genuinely ABSENT org
row is NOT an error and falls through to the env default, which is what keeps
an organisation that never touched this lever behaving exactly as before. A
deployment with no database wired resolves to the env default too, and is not
failed closed. -
axonflow_segment_resolution_totalgains aphaselabel (#3473).
(Enterprise)
The counter previously carriedresultalone, pooling two call populations
with opposite error contracts: the observability-only resolution at
MCP-server session authentication, whose result is read by nothing, and the
policy-affecting resolution that denies on failure. Aphaselabel now
separates them, with valuessession_auth,enforcementandpreview.
Adding a label changes series identity, so a dashboard panel, recording
rule or alert that selects the old unlabelled series will go permanently
empty; re-point it at the phase you actually mean, or aggregate the label
away. The companion histogram
axonflow_segment_resolution_duration_secondsis deliberately NOT split,
because latency is a property of the resolver and its cache rather than of the
call site.Two signals are corrected alongside it.
axonflow_segment_policy_fail_closed_totaland its paired "DENYING" log line
now come from the enforcement phase only. The counter's help text says it
counts requests DENIED, and the portal's policy-test preview returns200and
denies nothing, so clicking Test during a segment-store outage used to add
to a denial counter and log a denial per click, and anyone alerting on it
paged on button presses. Expect this counter to read lower; the difference
is denials that never happened. The session-authentication phase is exempted
by the same rule, but it never incremented this counter to begin with (it
reached the shared resolver by a different path), so nothing changes for it
beyond the exemption now being structural rather than incidental. -
POST /api/v1/mcp/check-inputidempotency entries are scoped to the calling
principal (#3447). (Enterprise)
The idempotency store keys on(key, tenant_id, endpoint)and answers a hit
by returning the cached body without invoking the handler, so the new
segment gate would never run on a replay. Segment-scoped policy makes the
verdict depend on the principal, so the principal is now part of the key: the
endpoint component changes from the literalmcp.check-inputto
mcp.check-input|p=<hash>, where the hash is taken over the caller's
validated email. It is hashed rather than embedded because
idempotency_keys.endpointis a plain text column read by operators and
sweeps, and has no business holding an address.Two consequences. An
Idempotency-Keycached before the upgrade will not
replay after it, because the endpoint component no longer matches; the request
re-executes, which for this route means it is re-evaluated rather than
replayed. And an operator query or sweep matching
endpoint = 'mcp.check-input'stops matching; match on the prefix instead.
Two different principals reusing oneIdempotency-Keynow get their own
entries rather than one replaying the other's verdict, which is the defect
being closed. -
A verified per-user identity is no longer displaced by
X-User-Emailin
audit attribution (#3447). (Enterprise)
OnPOST /api/v1/mcp/check-inputandPOST /api/v1/mcp/check-outputthe
trustedX-User-Emailheader won unconditionally, so under
AXONFLOW_TRUST_IDENTITY_HEADERSa caller who had presented a validated
per-user token could still have the audit row named after the header value.
Once segment-scoped policy keys enforcement on the validated identity, a row
naming a different principal does not merely mislabel the caller, it
misdescribes what was governed in the artifact the compliance exports read.
The header now applies only where NO per-user identity was verified, which is
the shared-credential case it was introduced for and which is unchanged.
Audit rows for token-bearing callers on those two routes will name a
different email than they did before, and that email is the one enforcement
actually used. OnPOST /api/v1/decidethe displaced header value survives as
a claim atpolicy_details->>'attempted_user_email'; on these two routes it
is not recorded. -
A
/api/v1/unified/executionslist with neither a tenant nor an org key is
now401, and an org-wide list with no BYPASSRLS admin pool is now500
(#3367). (Community / Enterprise)
The first shape used to fall through to an unscopedWHERE 1=1read of every
organisation's executions and return200. It now returns401 UNAUTHORIZED
withTenant or org identity required, matching what the by-id half of the
same handler already returned for the same input. The second shape is new:
the portal's org-wide read is refused with500 INTERNAL_ERRORwhen
axonflow_app_roleis in use and no BYPASSRLS admin pool was installed,
because that read would otherwise be filtered to zero rows by RLS and restore
the confident-empty page this change exists to remove. The remedy is to set
AXONFLOW_DB_PLATFORM_ADMIN_URL.That arm is defence in depth, and in practice unreachable, because the
deployment it describes does not boot.initializeComponentscalls the
platform-admin boot guard unconditionally, and the guard fires on exactly this
configuration, because an UNSETAXONFLOW_DB_USE_APP_ROLEcounts as ENABLED
(onlyfalse,FALSE,Falseor0disable it) and a blank or
whitespace-only admin URL is the other half of its predicate. The deployment
crash-loops at startup; the route
is never served. The guard is not new in this release and is not
edition-specific: there is no Enterprise overlay of the orchestrator's
run.go, and the call has been on that boot path since at least 9.16.1
(#3319 renamed the caller label it reports under, nothing more). The500
arm stays in the code and is documented here on purpose, because a guard
and the thing it guards are separately maintained, and the route must not
quietly go back to a confident empty page if the guard is ever relaxed. Two
further500arms guard an unnormalized org key and a
request asserting org-wide and tenant-scoped reads at once. The org-wide
branch is reached only over the new trusted-hop header
X-Axonflow-Tenancy-Scope: org, which the agent strips from every inbound
client request, so no governed caller can assert it. -
The in-memory
DynamicPolicyEngineis deleted; boot no longer falls back to
it, andAXONFLOW_DEBUG_POLICIESis removed (#3319). (Community)
DatabaseDynamicPolicyEngineis now the only dynamic-policy engine.
Previously a database failure at boot constructed a second, in-memory engine
and left enforcement permanently on built-in defaults until a restart. The one
engine now 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.AXONFLOW_DEBUG_POLICIESno longer exists: it only ever
controlled the deleted engine's verbose logging, and setting it now does
nothing. Theplane="memory"label value of
axonflow_policy_condition_unevaluable_totalis no longer emitted; the
remaining values (database,mcp,policy_test) are unchanged, so a
dashboard or alert matching onplane="memory"will go permanently empty. -
avg_latency_msis nullable on the audit report and audit summary, and the
mean is taken over measured rows only (#3424). (Community)
POST /api/v1/audit/reportandPOST /api/v1/audit/summaryused to compute
COALESCE(AVG(response_time_ms), 0)and return a non-nullable float, so a
window with nothing measured reported a confident0ms. Both now select the
real average and a sample count, and return an explicit JSONnullwhen there
is nothing to average, alongside a newlatency_sample_count. On
/audit/reportthe divisor also changed from every verdict row to the
measured rows only. This is a deliberate consumer-visible contract change
and is recorded as such in.github/oasdiff-err-ignore.txt; the companion
change on/audit/summaryis additive there because the property was
previously undocumented. A client bindingavg_latency_msto a non-optional
float will break. The measured predicate also relaxed from
response_time_ms > 0toresponse_time_ms IS NOT NULL, so a genuine
sub-millisecond decision is now a sample rather than a discarded row. -
tokens_used,costandresponse_time_msare omitted rather than sent as
0, and the CSV writes an empty cell (#3424, #3427). (Community)
OnPOST /api/v1/audit/search,POST /api/v1/audit/export(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. They are now pointers withomitempty, so the key is absent on
a row with nothing recorded and present with its real value otherwise. In the
CSV, thetokensandresponse_time_mscells are empty rather than0,
which is what stops a spreadsheetAVERAGE()from counting them. A genuine
measured0still renders as0. A consumer that indexes these keys
unconditionally, or that treats a missing key as an error, must handle
absence. -
POST /api/v1/audit/reportreturns500where it previously returned a
partial table (#3426). (Community)
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 is explicit that no
report was produced and that this is not a report that nothing was found. The
siblingPOST /api/v1/audit/summarydeliberately does NOT fail: it degrades
with a newtop_policies_unavailableflag instead, because a summary tile
going quiet is not the same as a compliance report going short. -
The legacy SEBI export gains
status: "partial"and omits
summary.compliance_scoreon an incomplete export (#3435). (Enterprise)
See the Enterprise section for the full item. This applies to
POST /api/v1/sebi/audit/exportonly. In contract terms, on that route:
compliance_scoreis now a nullable field that is ABSENT rather thannull
or0whenever a section could not be produced,statusgainspartial
alongsidecompletedandfailed, andsummary.report_stateplus
summary.sectionsare the fields a caller must read instead. An
integration on that route keying onstatus === "completed", or binding
compliance_scoreto a non-optional float, breaks on upgrade. The
nullability is recorded in.github/oasdiff-err-ignore.txtwith the
measurement behind it.POST /api/v1/compliance/reportsis a different contract and does not
change. Its status vocabulary ispending/processing/completed/
failed(platform/orchestrator/compliancereport/types.go) and gains no
fifth value, so a facade caller will keep seeingcompletedand must not
wait for a status transition to learn that a pack is incomplete. On that
route completeness is carried inside the document, in the SEBI pack's
"Report completeness" block, and surfaced in the portal by the new
completeness caveat described under Enterprise. -
The declarative workflow engine's run ids are
wfe_, notwf_(#3442).
(Community)
Ids returned byPOST /api/v1/workflows/executechange prefix. That route
runs a spec handed to it in the request body and appears inworkflows,
workflow_stepsandexecution_historynowhere 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 thewf_prefix stops matching. Ids minted
before this release keep their old prefix; no migration, no row rewritten. -
Two seeded dynamic policies begin evaluating, and a never-tuned duplicate is
deleted (#3321, migrationcore/160). (Community)
risk_scoreis computed by the platform again rather than read out of the
caller's own request body, sosys_dyn_high_risk_block(risk_score > 0.8, actionwarn) andsys_dyn_anomalous_access(risk_score > 0.6,
actionalert) start firing on matching requests that previously passed
unconditionally. Neither newly blocks: both are allow-but-annotate.
Migrationcore/160deleteshigh_risk_block, a 2025 duplicate of
sys_dyn_high_risk_blockthat migration 036's downgrade towarnnever
touched and which would therefore have started BLOCKING production traffic on
upgrade beside its tuned twin. Review the 0.6 and 0.8 thresholds against the
new weights before rolling out. -
GET /api/v1/usage,GET /api/v1/usage/summaryand
GET /api/v1/audit/session-summaryreturn nullable latency (#3436).
(Enterprise)
avg_latency_msand the rawlatency_msbecome pointers, returning explicit
nullrather than a fabricated0, with newlatency_sample_countand
latency_bucket_countcompanions on/usage/summaryand a
latency_sample_counton each session-summary bucket and tool. A separate,
larger consequence on/api/v1/usage: because the scan loop skipped rows it
could not decode into a non-nullable int, everyclaude_code_metricrow was
silently dropped from the response and the CSV behind a200 OK. Those rows
now appear, so response and export row counts can rise on upgrade. -
The SAML SP signing keypair is never fabricated; affected tenants are
refused instead (#3341). (Enterprise)
When the customer portal could not read its SP signing keypair from Secrets
Manager, it logged a warning and minted a throwaway RSA keypair per process
start, then installed it as the deployment-default SP signing identity. SAML
then looked healthy: metadata served, logins worked, nothing was refused. But
the SP certificate changed on every portal restart, so any identity provider
pinning it began rejecting logins with a signature mismatch reported at the
IdP, uncorrelated with a boot-time warning line nobody was watching.The temporary-keypair path is deleted. A load failure is recorded, the default
keypair stays unset, and the failure is scoped to exactly the tenants it
affects: a tenant is refused at SSO-configuration-load time, and its SP
metadata is refused, only when that tenant does not store a COMPLETE keypair
of its own. Note the completeness: a tenant missing itssp_private_key,
itssp_certificate, or both is refused, because a half-configured tenant
used to silently inherit the fabricated half. A tenant storing both is
completely unaffected, which is why this is a per-tenant refusal rather than a
boot failure.Where the diagnostic text goes, and where it deliberately does not. The
refusal's full text names the Secrets Manager secret, wraps the underlying
load error and states both remedies, because an operator cannot act on it
otherwise. That text is served only on operator surfaces: the boot log,
the portal log line written on every refused route, and the
session-authenticatedPOST /api/v1/sso/config/test. The pre-auth SAML
routes get a generic body instead.GET /auth/saml/{tenant}/loginanswers
400and the callback answers401with "SAML is unavailable for this
tenant; contact your administrator", and SP metadata answers a generic500.
This is not cosmetic: the underlying load error is the raw cloud SDK error,
which on the access-denied shape carries the portal's own IAM principal and
account identifier, so echoing it to an unauthenticated caller on a login URL
would be infrastructure reconnaissance. The refusal is classified by an
exported sentinel rather than by matching its message, and every other error
on those routes keeps its existing echo unchanged, since those texts are
benign tenant-facing diagnostics.The portal still starts, and that is deliberate. Its only caller treats a
SAML constructor error as graceful degradation: it logs, keeps the service nil
and registers no SAML route at all. Failing at construction would therefore
have removed SAML for every tenant, including the ones that never needed the
deployment default.A new administrator-facing surface says so before any user finds out.
POST /api/v1/sso/config/testreturns400with a failing result for a SAML
configuration that would be refused at login for this reason, and reports the
underlying load error in the detail field. An OIDC configuration, and any SAML
configuration carrying both halves of its own keypair, are unaffected and test
as before.
An operator whose deployment has been running on a fabricated keypair will
see SAML stop working for affected tenants on upgrade rather than continue to
half-work; grant the portal read access to the secret and restart, or store
a per-tenant keypair in each affected tenant's SSO configuration.
Security
-
The Workflow Control Plane no longer bypasses the HITL enforcement
chokepoint (#3517). (Community)
platform/orchestrator/hitl_wcp_community.goandhitl_wcp_enterprise.go
each wroteINSERT INTO hitl_approval_queuedirectly, so a workflow approval
skipped the licence-tier gate, the pending cap and the history trail that
every other entry point applies. This is the same class #1998 closed for the
MCP toolaxonflow_request_approvalin v7.8.0 (2026-05-07), and that fix
shipped without a ratchet. These two were not a later regression:
git log --diff-filter=Aputs both files at9f19c3531, 2026-01-25
(#1082), so they already existed when #1998 landed and its fix passed over
them. They went undetected for seven months. Both writers now route through
platform/agent/hitl/queue, which holds the only
INSERT INTO hitl_approval_queuein the non-test tree, and
a lint job in this repository's CI fails the build if a second one appears.
(The guard script itself lives underscripts/, which is not part of the
community mirror, so it is named in the CI / Testing section rather than
here - a community reader would find nothing at that path.) -
Policy selection was keyed on a value the caller supplies, and the boundary
it expressed was never enforceable (#3490). (Community / Enterprise)This is an admission, not a simplification. Until this release, which
policies applied to a caller was decided bytenant_id. That value is the
username half of the Basic-auth credential:platform/agent/db_auth.go
assigns it verbatim with the comment "From Basic auth username", and
nothing validates it against a directory, a credential record or the
licence. Any caller could therefore select another tenant's policy set
inside its organisation by choosing that tenant's name, and a caller that
chose a name no policy targeted was governed by no tenant-tier policy at
all.Measured on a live stack before the fix, with ONE Enterprise licence and
three usernames differing in nothing else:username=alpha alpha's rule: blocked beta's rule: ALLOWED username=beta alpha's rule: ALLOWED beta's rule: blocked username=zzz alpha's rule: ALLOWED beta's rule: ALLOWEDThe dynamic-policy plane behaved the same way and had less protecting it: its
cache is loaded across every tenant through the platform-admin
(BYPASSRLS) pool, so that string comparison was the only boundary behind
it. Row-level security never keyed ontenant_idat all - it keys on
org_id- so no deployment posture, app-role included, ever enforced the
boundary that per-tenant policy targeting appeared to draw.Selection now keys on
org_id, which comes from the signed licence payload
and which the caller cannot choose.tenant_idremains on every row and in
every audit record as attribution - which credential produced a decision.
It no longer selects.What this means for you, in the direction it changes.
-
One tenant per organisation: nothing changes. Measured as a set
difference in both directions over a seeded database: the same 94 policies
before and after, none gained, none lost. -
Several tenants in one organisation, with different POLICY rows: those
rows begin applying organisation-wide. The direction is over-blocking - a
restriction applies more broadly and nothing stops being enforced. -
Several tenants in one organisation, with policy rows covering only SOME
of them: the same thing, and it is the easier case to miss. An
organisation with one rule scoped to one of its three tenants has no
divergence among its own rows, and that rule still starts governing the
other two../preflight.shcheck 23 reports this shape separately. -
Override rows do NOT become organisation-wide, deliberately. A
policy_overridesrow downgrades an action or disables a policy, so
applying one across an organisation would be the only part of this change
that LOOSENS enforcement - which is exactly why it is the one part that was
not made organisation-wide. Override selection still narrows to the
caller's own tenant plus the organisation's own rows, on the reasoning that
an override is an exception granted to a caller rather than a policy
targeted at one../preflight.shcheck 23 still lists organisations that
authored divergent per-tenant overrides, because that is worth knowing
before a tenancy change and those rows are the ones to revisit if
per-tenant overrides are ever retired too.One override shape does start applying, and it is the only loosening in
this release. Apolicy_overridesrow with a NULLtenant_idis an
organisation-wide grant by construction. Until now it was inert on the
enforcement path for two compounding reasons, both defects. That plane's
org leg requiredorganization_id IS NOT NULLand compared that column
against the caller's organisation - andorganization_idis the legacy key
no shipped migration ever populated, so it is NULL on every row a shipped
writer produced (0 of 101 on a fully-migrated seeded database). The leg
therefore matched nothing on every caller path, not only the one where
the agent passed the argument as a literal nil and it arrived empty.
Selection is keyed onorg_idnow and the row applies. Neither shipped writer produces such a row - both set
tenant_id- so this reaches hand-inserted and pre-v9 rows, and check 23
counts them by name of shape rather than predicting there are none.One override shape stops applying, in the safe direction. Where the
enforcement plane cannot resolve an organisation it falls back to the
tenant, and an override authored through the portal carries the real
org_id, so on such a deployment the two no longer meet and a deliberate
blocktowarndowngrade silently stops working. The result is
over-blocking rather than under-blocking, but it is a behaviour change and
not an intended one; the fix is to make the organisation resolvable, which
on any licensed deployment it already is.One related fix ships in the same clause, and it TIGHTENS. This read
never filteredrevoked_at, while the session-override matcher always
has, so a revoked override went on being applied indefinitely. The
enforcement read filters it now. If anything on your deployment was
depending on a revoked override still taking effect - a downgrade someone
revoked but whose workload kept running on it - that stops here, in the
direction of more enforcement rather than less. It makes revoking work the
way you would already have assumed it did.
Before upgrading, run
./preflight.sh. Check 23 reports three
populations, and the second is the one to read carefully: organisations that
target policy or overrides at more than one tenant, named by
org_id / tenant_id / policy_id; organisations whose policy rows cover only
SOME of the organisation's tenants, which carries no divergence among its own
rows, can be a single rule, and still starts governing every sibling tenant;
and a count of org-scoped override rows (tenant_idunset), which were inert
on the enforcement path before this release and apply now. Check 24 names
every policy row that migrationcore/165will make unselectable. The
re-authoring path is in the v10 upgrade guide: a rule that genuinely belongs
to a subset of your people becomes segment-scoped (ADR-060, the verified
sub-org dimension resolved from your SCIM directory), and a rule whose
per-tenant scoping was incidental is consciously accepted as
organisation-wide.The tenant-scoped override rows need no action for this upgrade. They are
deliberately not made organisation-wide, because applying ablocktowarn
downgrade across an organisation is the one direction of this change that
would LOOSEN enforcement. Check 23 lists the ones that diverge per tenant so
you can confirm that is what you intended. Revoking one is not a remediation
step here, and revoking a deliberate downgrade would put a production
workload into hardblockfor no reason.Why this was dropped rather than rebuilt on a verified key. Keeping
per-tenant targeting would mean maintaining two sub-org dimensions, one
verified and one forgeable, with every enforcement plane forever answering
which of them wins. Per-application policy on a verified key is not
foreclosed: the building block is a real credential-to-tenant binding
validated at authentication, recorded as unblocked and unscheduled.One operational consequence. Roll the agent and the orchestrator
together. The agent now sends the organisation id on its internal
policy-evaluation call, and an orchestrator on this release refuses that call
with403when it arrives without one. That refusal is deliberate:
evaluating without an organisation would silently drop every tenant-authored
dynamic policy behind a200, and a governed tool call must fail closed
rather than quietly stop being governed.A second operational consequence, on the orchestrator's operator routes.
Seven of them now answer401with the codeORG_REQUIREDwhen the request
carries noX-Org-ID:POST /api/v1/templates/{id}/apply,
POST /api/v1/policies/import,POST /api/v1/dynamic-policies/import, the
dynamic-policy list, and the three policy-simulation routes
(/api/v1/policies/simulate,/api/v1/policies/impact-report,
/api/v1/policies/conflicts), the last four through one shared
resolveOrgOrFailhelper so the refusal cannot drift between them. The
template route is the one whose status changes for a previously working
caller: it answered201. Applying a template CREATES a policy, and after this release
a policy is selected by its organisation, soPolicyRepository.Create
refuses a blank one; this route was its second caller and the one that
carried no organisation at all. It is refused rather than defaulted to the
tenant, and that choice is the point: stamping the new row with the
Basic-auth username is exactly what this release removes, so on any
deployment whose licence organisation differs from that string the template
would apply, return201, list correctly, and govern nobody. A401naming
the header it wants is the honest answer; a201that enforces on no one is
not. Unlike the policy-CRUD prefixes this route is NOT proxied by the agent,
so no gateway sets the header on the caller's behalf: it is reached directly
on the orchestrator, which is an operator plane, and the operator supplies
the organisation the policy is being authored for.And one refusal on the internal plane, deliberately
403rather than
400. The orchestrator's MCP dynamic-policy evaluation route refuses a
header-less internal-service caller that sendstenant_idbut no
organization_id. The status is chosen, not incidental: the agent's
EvaluateWithGracefulDegradationabsorbs a400by degrading to allow-all
and refuses to absorb401or403(#3068). A400here would have turned
a version-skew window into silent allow-all on every governed tool call,
which is the exact failure this release exists to close. -
-
The policy-simulation dry run evaluated an unvalidated, body-supplied
organisation (#3490). (Community / Enterprise)
POST /api/v1/policies/simulateforced the caller's tenant from the
gateway-stamped header onto bothUserandClient, but left
client.org_idexactly as the request body sent it. That field is not inert:
the dynamic-policy evaluator resolvesclient.org_id(and its bareorg_id
alias) directly to it as a policy-condition field, so a caller could name any
organisation string and change which conditions matched in the simulation
result the route handed back. The handler now forces bothUser.OrgIDand
Client.OrgIDfrom the authenticated request, and refuses with
401 ORG_REQUIREDwhen there is no organisation to force.Scope, stated narrowly, because the narrow version is the accurate one.
This did not let a caller reach another organisation's policies. Policy
SELECTION on this route was keyed on the tenant rather than the organisation,
throughListActivePoliciesForTenant, and the tenant was already forced from
the gateway header before evaluation; thepolicy_metricsrow the dry run
writes was scoped by that same forced tenant, never by the body value. What
the request body could reach was CONDITION EVALUATION inside a dry run, on a
route that authors no policy and changes what nobody else is served. No
released version evaluated one organisation's policy set on another
organisation's behalf through this route.The sibling routes
/api/v1/policies/impact-reportand
/api/v1/policies/conflictswere never exposed to this, for a duller reason
than a better-resolved scope: neither builds aClientblock at all, so
neither has a body-supplied organisation to validate. -
A policy row with no resolvable organisation stops being selectable
(#3490, migrationcore/165). (Community / Enterprise)core/165makesorg_idNOT NULLwith a non-emptyCHECKon
static_policies,dynamic_policiesandpolicy_overrides, because after
this release that column is the only thing that selects a policy row. A
column that selects everything cannot be allowed to be empty.The migration resolves what it can, in six steps: the
globalwildcard;
then, ondynamic_policiesonly, rows with no tenant at all are mapped to
global, because on that table an absent tenant is the apply-to-everyone
shape; then thetenantsmapping, the only step that can move a row from one
organisation to another; then the legacyorg_id == tenant_idcollapse; then,
onpolicy_overridesonly, theorganizationslookup through the legacy
organization_id, which is how org-scoped override rows are reached at all;
and finally anything still unresolved is stamped__axonflow_unowned__.Step 5 is narrower than it reads, and the difference decides which rows
survive. It joinsorganizations.id::textagainst
policy_overrides.organization_id::text, and those were never the same kind
of value:organizations.idisSERIAL, an integer, while
organization_idwas declaredUUIDand stayedUUIDuntilcore/133
retyped it totext. An integer's text form cannot equal a UUID's, so step 5
resolves only INTEGER-shaped legacy values and a UUID-shaped one falls
through to the sentinel. Preflight check 23 counts the rows step 5 can
rescue; check 24 reports the ones it cannot, as rows that stop firing. The
two partition the population rather than overlapping on it.A stamped row stops being able to fire, because the platform refuses that
sentinel on both sides of every comparison. This is the one change in
v10.0.0 that REMOVES enforcement rather than widening it, which is why it
is called out here rather than left in the migration list. The migration
raises a warning naming the affected rows, but it raises it during the
upgrade in the agent's boot log, which is the wrong moment to learn it.
./preflight.shcheck 24 reports the same rows read-only beforehand, using
the migration's own resolution chain, so the two agree.Decide per row:
UPDATEorg_idwith the owning organisation if the rule
should keep applying, or accept that it stops. A rule with no owner was
already unreachable under row-level security on any app-role deployment, so
accepting is usually correct - but it should be a decision rather than a
discovery. -
The legacy
organization_idcolumn is dropped from the policy tables
(#3334, migrationcore/166). (Community / Enterprise)The headline is that this WIDENS what you can write, and the schema was
incoherent with the new model until it did.policy_overridescarried
core/030'svalid_override_scopeCHECK,
(organization_id IS NOT NULL AND tenant_id IS NULL) OR (tenant_id IS NOT NULL),
so an org-scoped override in the shape this release's own model implies -
organization_idNULL,tenant_idNULL,org_idset - was REJECTED on
insert. Sincecore/133the shipped writer deliberately leaves the legacy
column NULL, so the only way to author an org-scoped override was to populate
a column already documented as deprecated.core/166drops the column and
the CHECK falls with it as a dependency; the shape becomes writable for the
first time. It is deliberately not replaced -core/165'sNOT NULLon
org_idcovers what it was for, unconditionally and on all three tables.static_policies,dynamic_policiesandpolicy_overrideslose
organization_id. It was the pre-v9 organisation key, superseded byorg_id
and carried since only for the rows that had not been migrated off it -
which is whycore/166runs aftercore/165, the migration that resolves
the last of those rows. What breaks is SQL that reads the column directly
against these three tables, and any external report or dashboard built on it;
move such queries toorg_id, which carries the same meaning and is now
guaranteed non-empty.No API field is removed, and an integrator should not assume otherwise.
EffectivePoliciesand its response type keeporganization_id
(platform/agent/policy_types.go:215), as doDynamicPolicyRequestand the
policy API's own resource type. Only the database COLUMN goes. What does
change on the wire is the opposite of a removal: the policy API's
organization_idwas populated FROM the legacy column, so it was blank on
every row, and it now carries the real organisation key. An integrator that
coded around the field always being empty will start seeing a value. -
An unscoped cross-organisation execution read is refused (#3367).
(Community / Enterprise) A/api/v1/unified/executionslist carrying neither a tenant
nor an org key ranFROM execution_history WHERE 1=1across every
organisation and answered200. It is now401, and the repository refuses
the same shape independently rather than relying on the handler. See the
Breaking changes section. -
Segment-scoped policies were silently unenforced on five enforcement
planes (#3312, #3281, #3430, #3447, #3456). (Enterprise) On the gateway
pre-check, the WCP HTTP step-gate, the MCP-server tools, the four MCP REST
routes andPOST /api/v1/decide, a segment-scoped policy that blocked a
member on/api/requestwas filtered out before evaluation and the request
passed. All five now resolve segments through the one shared, fail-closed
resolver.DELIBERATE DISCLOSURE, and it has TWO parts. Do not assume segment coverage
is now complete.Part one: TWO enforcement paths still evaluate org-only, and they are
different shapes.The first is
POST /v1/chat/completions, the OpenAI-compatible endpoint
(#3410), on its request-phase evaluation. A segment-scoped policy does not
restrict a member who reaches your platform through it, and
require_user_tokendoes not close it either. That endpoint mirrors
OpenAI's wire shape, which carries nowhere to put a per-user identity: it
resolves every caller with an empty per-user token and synthesises a service
email from the authenticated client id. On every other plane a refusal is
something a caller can comply with by presenting the token it already has;
here it would be a wall no caller could comply with, so refusing would simply
make the endpoint unusable rather than make identity mandatory. Closing it
needs a verified machine principal, tracked as #3410 behind #3279.The second is the RESPONSE phase of
POST /api/v1/process, the Gateway Mode
plane. Its REQUEST phase is segment-aware and unchanged by this release; the
response-phase scan passes an unconditionally empty segment set, so a
segment-scoped response-phase policy is skipped there. Its exposure is
NARROWER than the OpenAI-compatible endpoint's: that scan evaluates only the
PII and sensitive-data categories your detection config has enabled, not the
general policy set. It is still an enforcement gate rather than a detection
scan, because a policy whose effective response-phase action isblock
withholds the LLM response on that route. Not tracked under the ADR-060
slices that closed the five planes above.Part two, and this is the part an operator is most likely to get wrong: on
the four MCP REST routes and/api/v1/decide, enforcement is CONDITIONAL on
the caller presenting a per-user token. A caller who presents none is
evaluated org-only, with no refusal, exactly as before. So a member can still
evade a segment-scoped policy on those five routes by declining to send the
token that identifies them, unless the organisation sets
require_user_token(see BREAKING CHANGES), which refuses a token-less
enterprise caller at authentication. The two newly-covered plane families are
therefore a real fix for a caller who identifies themselves and NOT, on their
own, a closed door.Two of the other three planes are not exposed to that choice; the third
is. The MCP-server JSON-RPC plane refuses a principal-less caller outright
whenever the phase's policy set can depend on membership, with no flag
required. The gateway pre-check refuses any enterprise caller whose per-user
token is absent or invalid, which is whyrequire_user_tokenhas nothing to
change there. The WCP HTTP step-gate IS exposed to it, and
require_user_tokendoes not reach that plane at all. Its identity header
cannot be forged (it is honoured only behind the internal-service proxy-auth
gate, and the agent strips a client-suppliedX-User-Emailunder the
default-off trust gate), but it can be OMITTED: a governed caller that sends
noX-User-Tokenreaches the step-gate with no email, resolves to the
org-only outcome, and is not refused. Treat the WCP step-gate the same way as
the MCP REST routes for now, and keep an edge gate or an org-scoped policy in
front of it.This is published rather than left to be discovered because an operator who
believes a segment-scoped policy covers every plane is worse off than one who
knows where it does not. What to do, stated as the concrete change from the
9.19.0 advice:- Keep gating
POST /v1/chat/completionsat the edge. Nothing in this
release changes that route. - Keep gating the response phase of
POST /api/v1/processat the edge if
you rely on segment-scoped response-phase PII or sensitive-data policy.
Nothing in this release changes that path either. - Do NOT treat the WCP HTTP step-gate as covered. It degrades to org-only
for any caller that omits its per-user token, andrequire_user_tokenhas
no gate point on that plane. - Do NOT drop an edge gate on the MCP REST routes or
/api/v1/decideon
the strength of this release alone. Turn onrequire_user_tokenfor the
organisations that rely on segment scoping first; that is what converts
conditional enforcement into an unconditional one, and only then is the
edge gate redundant. - Keeping an org-scoped policy behind the segment-scoped one still works,
and is still the answer that depends on nothing else. It costs nothing to
leave in place and it covers the OpenAI-compatible endpoint, the
/api/v1/processresponse phase and the WCP step-gate, which none of
the above does.
- Keep gating
-
The MCP-server plane no longer accepts a shared credential where a
per-user identity is required (#3430). (Enterprise) A caller with no
validated per-user token, or holding a token that names a shared synthetic
identity, is refused oncheck_policyandcheck_outputwhen the
organisation 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
(platform/agent/mcp_identity.go:478-483), because whether the verdict
depends on segments is then unknown. A database or policy-plane outage
therefore refuses these callers even on an organisation that holds no
segment-scoped policy.New metric
axonflow_agent_mcp_segment_identity_unresolved_total{tool}
(#3430) counts exactly that refusal, per tool. It is deliberately separate
from the existingaxonflow_segment_policy_fail_closed_total, which counts
resolver failures for a caller who did have a principal: the two have
different operator remedies (provision per-user tokens, versus repair the
identity store). This metric is Enterprise, and it is filed here rather
than under Community even though the counter is compiled into both
editions. In a Community build there is no identity-attribute resolver, so
resolveMCPServerSegmentsForPolicyproceeds at its first branch
(platform/agent/mcp_identity.go:466-468), the refusal it counts is
unreachable, and the series never acquires a label value. A Community
operator will not see it appear. -
Revoking a per-user token now revokes it on the MCP REST plane (#3472).
(Enterprise) Those four routes discarded any per-user token that failed to
validate and served the request under a synthetic service identity, sojti
revocation, expiry, algorithm pinning and signature verification had no effect
on them. A revoked token kept working. See the Breaking changes section: this
is a removed fallback, not an opt-in, and the refusal is audited under
user_token_rejected. -
Identity suppression is closable at authentication (#3476). (Enterprise)
Segment-scoped policy is only as strong as the platform's ability to insist on
an identity. Before this release, on every gate point that had a
synthetic-identity fallback, the caller chose: forwarding a per-user token was
optional, and declining it turned every segment-scoped restriction off for the
one person it was written to restrict.require_user_tokenmakes that choice
the organisation's rather than the caller's. It is off by default and must
be turned on deliberately, per organisation or deployment-wide; see the
Breaking changes and Migration sections for the levers, the six gate points,
the boot fatal, and the fail-closed behaviour on an unreadable posture.New audit marker
user_token_required, kept distinct from
user_token_rejectedso an alert can tell a caller who never tried to prove
who they were from one who tried and failed. The two have different remedies.
Query both with JSONB containment rather thanLIKE.New audit field
attempted_user_emailonPOST /api/v1/decide, at
policy_details->>'attempted_user_email'. It records anX-User-Emaila
caller asserted that did NOT become the row's attribution, in the same
attempted-versus-actual shape as the existingattempted_tenant_idand
attempted_org_id. Theuser_emailcolumn continues to name the principal
the decision was actually evaluated against. It is absent when no header was
asserted, and when the asserted value agrees with the column. -
New metric
axonflow_segment_subject_org_mismatch_total(#3447).
(Enterprise) Counts segment resolutions where the validated token'sorg_id
claim disagreed with the authenticated credential's organisation. This
cannot escalate: segment ids are org-scoped group identifiers, so groups in
the asserted organisation can never match a policy targeting the governing
one. What it can do is UNDER-enforce in a way that leaves no other trace: the
lookup joins to zero rows, which is correctly reported as a successful empty
resolution, and a verified member of a targeted segment is then evaluated
org-only with no refusal, no audit row, and a metric indistinguishable from a
genuine non-member.It is reached when a token is minted with no
org_idclaim, or with one
defaulted to the tenant id, on a deployment where the organisation and tenant
identifiers genuinely differ. A non-zero value on this counter means some of
your segment-scoped policies are silently not applying; mint per-user tokens
with anorg_idmatching the credential's organisation. A rate-limited
warning carries the diagnosis and this counter carries the volume. It is
reported rather than refused because refusing would break exactly the
deployments whose tokens default the claim, and because the already-shipped
/api/v1/processand gateway pre-check planes use the same subject key, so
refusing on one plane would diverge the three. -
Gateway Mode LLM call records now carry the organisation that made the
call (#3435). (Community)llm_call_audits.org_idhas existed since
migration 089, and the writers disagreed about it in two different ways.
The Gateway Mode writer (storeLLMCallAudit) omitted the column from its
INSERT entirely, so every Gateway Mode row landed with a NULL organisation
and could be scoped only by the client credential. The OpenAI-compatible
writer already bound the column, but bound a bare string, so a request with
no resolved organisation planted an empty string, which is a value no org
predicate can subsequently claim. All three writers now stamporg_idfrom
the authenticated identity, and all three route a blank organisation through
the same helper so it is written as SQL NULL rather than as an empty string,
so one predicate reaches every row. This can lengthen how long those rows
are kept, and only lengthen it: the retention sweep buckets byorg_id, a
NULL-org row falls into the default window, and per-org 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: NULL from Gateway Mode, and either a real
organisation or an empty string from the OpenAI-compatible path.
Migration
-
EIGHT new migrations:
core/160throughcore/167. All eight ship to
every edition. Run them with the usual boot-time runner; the deploy delta
from 9.19.0 is images plus these eight. Only161and162carry a sizing
risk; see the joint operator note below.163,164and166are cheap and
none of them rewrites a table.165takesACCESS EXCLUSIVEon three
config-scale tables and does not rewrite them either, but it is the only one
that can change what your deployment ENFORCES - read its entry. -
core/165makes the organisation key mandatory on the policy tables, and
a row it cannot resolve one for stops firing.static_policies,
dynamic_policiesandpolicy_overridesgetNOT NULLplus a non-empty
CHECKonorg_id, because after #3490 that column is the only thing that
selects a policy row. The migration resolves what it can - theglobal
wildcard; then, ondynamic_policiesonly, rows with no tenant at all, which
map toglobalrather than to the sentinel because on that table an absent
tenant is the apply-to-every-tenant shape; then yourtenantsmapping; then
the legacyorg_id == tenant_idcollapse; then theorganizationslookup
for an org-scoped override - and stamps anything left with
__axonflow_unowned__,
a sentinel the platform refuses on both sides of every comparison. A
stamped policy row is then selectable by nobody, which is the one change in
this release that REMOVES enforcement rather than widening it. It raises a
WARNINGnaming those rows, but it raises it during the upgrade in the
agent's boot log;./preflight.shcheck 24 reports the same rows read-only
beforehand, using the migration's own resolution chain. A paired down
migration relaxes the constraints; it deliberately leaves the sentinel stamps
and the resolved backfills in place, on the same reasoning migration 156's
rollback records - a rollback must not restore rows that carry no tenancy key
at all. -
core/166drops the legacyorganization_idcolumn from the policy
tables. Expect its WARNING on some deployments, and do not read it as lost
scope. The migration counts rows still carrying a value in the column and
raises aWARNINGnaming the count, because a dropped value cannot be
restored by the down migration. A row can appear in that count and still
carry a perfectly goodorg_id- some shipped policy bundles populate both -
so the warning is an inventory of what the drop discarded, not a list of
policies that stop working. The check that tells you what stops being
enforced is preflight check 24, and it is a different question.It was a second, differently-typed organisation key that shipped
migrations largely never populated (0 of 101 rows on a fully-migrated seeded
database, though a policy bundle can populate it),
and it is what made the two keys easy to confuse - it had already produced
wrong conclusions in two pieces of work. Metadata-only; no deployment data
depends on it. -
core/167adds one nullable column and widens one CHECK (#3509).
hitl_approval_queue.consumed_at TIMESTAMPTZ, nullable with no default and
no backfill: every existing row, including the Fraud and Risk entries
already in the field and every workflow step gate the orchestrator writes,
reads NULL, which is the correct "never consumed" state. Alongside it, two partial indexes - one over the rows the consume predicate can match and
one over the rows the retry-dedup lookup can match, which ask opposite
questions (approvedversuspending) and cannot share an index - and
hitl_approval_history'sactionCHECK widened to admitconsumed(without
it, every consumption history write fails the constraint).The locks, stated rather than implied. The migration is one transaction,
so every lock it takes is held until COMMIT, and both tables are locked at
ACCESS EXCLUSIVE:hitl_approval_queuefrom theADD COLUMN, and
hitl_approval_historyfrom the moment the CHECK is dropped. Reads to both
are blocked for the duration - for the approval queue that means the portal
Approvals page and the evidence and readiness exporters - so plan it as a
short write-AND-read pause, not a writers-only one. The WORK is small: no
table is rewritten, the column add is constant time, the CHECK is addedNOT VALID
and validated as a separate statement so its scan is explicit rather than
buried in the ADD, and both indexes are partial - one over approvals waiting to be
spent, one over holds waiting for a reviewer - rather than over queue
history. Nothing consumes anything until the
agent images are upgraded.The down migration removes the column and both indexes, and narrows the CHECK
back only when noconsumedrow exists - the history table is an
immutable audit trail, and deleting recorded evidence to satisfy a rollback
is not a trade this migration makes. Rolling the schema back WITHOUT rolling
the images back is the unsupported direction; roll the images first. -
core/160deletes one seeded policy row. It removeshigh_risk_block,
seeded by migrationcore/010(2025-11-20) and superseded by the
near-duplicatesys_dyn_high_risk_blockthat migrationcore/031
(2025-12-24) seeded later under the same name, conditions, priority and
tenant.high_risk_blockis the older of the two, and it is the one deleted:
migration 036'sblocktowarndowngrade tuned only thesys_dyn_row,
which is also the idplatform/agent/detection_config.gotreats as
canonical. Withrisk_scorenow computed (#3321), the
never-tuned duplicate would have started BLOCKING production traffic on
upgrade while its intentionally-tuned twin sat atwarnright beside it. A
paired down-migration restores it verbatim. The threshold this policy pair
enforces lives insys_dyn_high_risk_block'sconditionsJSON and is tunable
through the policy API and the portal like any other dynamic policy; it is NOT
thedynamic_policies.risk_thresholdcolumn both rows also carry, which is
read by nothing in this codebase. -
core/161andcore/162are WHOLE-TABLE backfills ofaudit_logs.
161setsresponse_time_ms = NULLon every historical row holding a
fabricated0;162setstokens_usedandcostto NULL on every
historical row where both coalesce to zero, at least one of the two is not
already NULL (the limb istokens_used IS NOT NULL OR cost IS NOT NULL, an
OR, so a row with one NULL and one fabricated0still matches), and the row
carries no provider and no model. Both are one statement in one transaction,
so each takes ROW EXCLUSIVE onaudit_logsfor its duration and writes a new
row version for every matched row; expect bloat proportional to the match
count until autovacuum catches up. Readers are not blocked.Both are bounded to
timestamp < NOW()and pinSET LOCAL TimeZone = 'UTC'so that bound means the same thing on every deployment; "whole-table"
is the description before the bound, and the bound exists for correctness,
not for speed. Expect a sequential scan anyway.idx_audit_logs_timestamp
does exist (migrations/core/059_runtime_tables_to_migrations.sql:55) and
timestampis a predicate column in both statements, buttimestamp < NOW()
selects essentially the whole table, so the planner seq-scans regardless; the
remaining predicate columns carry no index at all.162matched fewer rows than161when measured, and the migration
headers state that as a measurement on seeded history rather than as a
property of the predicates: seeded as the pre-release writers actually stored
history,161matched 140 rows and162matched 100 of those same rows,
with none matching162alone. The predicates do not imply the relation.
162never referencesresponse_time_ms, so it cannot entail161's
predicate; what the headers argue is the narrower point that162's extra
guards exclude writers161catches, such as the HITL approval writer, which
binds a literal0intoresponse_time_msand names neither usage column.
161was the larger on that measurement and runs first, so it is the
natural one to size against, but do not take the ordering on faith for your
own data: measure both with the counts below.Both down-migrations are deliberate no-ops, because a fabricated zero is
not recoverable from a NULL. -
core/163adds one nullable-free boolean column and changes nothing on its
own.organizations.require_user_token BOOLEAN NOT NULL DEFAULT false
(#3476). Additive, default false, no backfill and no data migration, so
every existing organisation keeps today's behaviour until an operator opts it
in. It is cheap regardless of table size:ADD COLUMNwith a constant
default has not rewritten the table since PostgreSQL 11, and the platform
ships against 15. The table's presence is probed through
pg_catalograther thaninformation_schema, which is privilege-filtered and
would let a role without a privilege onorganizationsread "table absent",
skip and commit having done nothing. A verification block fails the migration
loudly if the table was present and the column did not appear. A paired
down-migration drops it.Turning the column on is the behaviour change, not running the migration.
Read therequire_user_tokenitem in Breaking changes before you set it on
any organisation, and expect a flip to take up to one cache TTL (60 seconds by
default) to be live everywhere. -
core/164widensorganizations.license_keyfromVARCHAR(512)to
TEXT, and it is a metadata-only change (#3341). No table rewrite, no data
change, no value altered. It fixes a real onboarding failure whose trigger was
the length of a customer's own org id: the platform's keygen mints a V2 licence
key whose length grows with the org id, the permission grants and the expiry
payload, so a 16-character org id produced a 512-character key that fitted by
luck and a 17-character one produced a 515-character key that did not.
POST /api/v1/admin/onboard-customerthen answered500with a raw driver
error, after the key had already passed licence validation, so the refusal
came from storage and looked nothing like a licensing problem. The column
holds an opaque signed token whose length the schema cannot know, which is
what makesTEXTthe honest type. The look-alike columns that store
fixed-length hashes are correct atVARCHAR(512)and are untouched.Its down-migration is a deliberate no-op, matching the convention
161
and162set. Narrowing back toVARCHAR(512)would either fail outright or,
with aUSINGclause, truncate exactly the rows the up-migration exists to
admit, and a licence key truncated at 512 characters no longer validates.
Rolling the CODE back needs no narrowing: every reader treats the column as an
opaque string. -
JOINT OPERATOR NOTE: a tight
statement_timeouton a largeaudit_logsis
a BOOT LOOP, not a skipped step. The migration runner answers a migration
error with a fatal exit, so a161or162that trips a per-session or
per-rolestatement_timeoutwill fail the container, which will restart, and
fail again. Size the timeout against your ownaudit_logsrow count before
upgrading, or raise it for the migration role for the duration of the
upgrade. This is the first release in which a migration's runtime scales with
audit history rather than with schema size.The sizing basis, so this is actionable rather than a warning. Each
migration costs one sequential scan of the whole table (the planner will not
useidx_audit_logs_timestamp; see above), plus one new row version written
per MATCHED row, plus the bloat those versions leave until autovacuum catches
up. The scan is fixed by your table size and the write cost is fixed by the
match count, so measure both on your own data rather than inferring them
from the seeded numbers above. All three are read-only and safe to run
before the upgrade:SET LOCAL TimeZone = 'UTC'; -- scan size SELECT count(*) FROM audit_logs; -- rows migration 161 will rewrite SELECT count(*) FROM audit_logs WHERE response_time_ms = 0 AND timestamp < NOW(); -- rows migration 162 will rewrite SELECT count(*) FROM audit_logs WHERE COALESCE(tokens_used, 0) = 0 AND COALESCE(cost, 0) = 0 AND (tokens_used IS NOT NULL OR cost IS NOT NULL) AND (provider IS NULL OR provider = '') AND (model IS NULL OR model = '') AND timestamp < NOW();
Size the
statement_timeoutagainst the larger of the two match counts and
against a full-table scan. Ifaudit_logsis large, take an actual timing on
a restored copy rather than extrapolating; astatement_timeoutthat already
suits a full-table scan of it will suit these two. -
Do NOT re-run
161by hand after upgrading. Its predicate is
response_time_ms = 0, 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. -
A migration edited in place after it lands is silently skipped wherever it
already applied.161was edited after first landing on main. The runner's
applied-migration lookup keys on(version, name)and never compares a
checksum, so a database that already recorded161will not re-run the edited
file. This is harmless here, because161is in no released tag and every
deployment will apply the final text once. State the rule anyway: once a
migration is in a released tag, correct it with a NEW migration, never in
place. -
Behaviour change with no schema change: two seeded dynamic policies start
evaluating (#3321).sys_dyn_high_risk_block(risk_score > 0.8,warn)
andsys_dyn_anomalous_access(risk_score > 0.6,alert) have been unable
to fire on a real signal since January, because the engine read the score out
of the caller's own request body rather than computing it. Both are
allow-but-annotate, so neither newly blocks. Review the two thresholds
against the new weights (SQL injection +0.9, an anchored sensitive-data
keyword +0.7, aselect *query +0.3) before rolling out. -
Boot-requirement change:
AXONFLOW_DEBUG_POLICIESno longer exists
(#3319). It controlled only the deleted in-memory engine's verbose logging.
Setting it now does nothing; remove it from your deployment configuration to
avoid implying a behaviour that is gone. -
Boot-requirement change:
AXONFLOW_DB_PLATFORM_ADMIN_URLbecomes
load-bearing for the portal Executions read (#3367). A deployment running
axonflow_app_role(the default since v9.0.0, disabled only by an explicit
false) with no BYPASSRLS admin pool now gets a500on that route instead of
a confident empty page.Nothing to run, and for most operators nothing to do. The orchestrator
already refuses to BOOT on exactly this configuration, and has since before
9.19.0: the platform-admin guard runs unconditionally at startup and fires
when the app role is enabled (which an UNSETAXONFLOW_DB_USE_APP_ROLEis)
and the admin URL is blank. So the500arm is a backstop behind a boot
refusal rather than the failure an upgrading deployment will meet, and a
deployment that boots on 9.19.0 is by definition already configured for it.
The variable still matters if you are turning the app role on, in which
case set it in the same change, or the orchestrator will crash-loop. A
one-time warning is logged when the fallback is taken on a pool where it is
harmless. -
Behaviour change with no schema change:
plane="memory"is no longer
emitted (#3319). Any recording rule, dashboard panel or alert matching
axonflow_policy_condition_unevaluable_total{plane="memory"}will go
permanently empty. The remaining values are unchanged. -
Behaviour change with no schema change, and WHICH ROUTE YOU CALL DECIDES
WHAT YOU DO: the legacy SEBI export gainspartial; the compliance-reports
facade gains nothing (#3435). Nothing to run, but this is the item most
likely to be discovered by a regulator rather than by an operator. Read the
Enterprise section before your next reporting cycle. On the legacy
POST /api/v1/sebi/audit/export, move any integration off
status === "completed"and off a non-optionalcompliance_score, and
handlefailedas well aspartial. On the
POST /api/v1/compliance/reportsfacade there is no status change to react
to: it still reportscompletedfor an incomplete pack, so read the
document's "Report completeness" section, whose very presence means the pack
is incomplete, or the portal's completeness caveat, instead of the job
status. Either way, expect a stock deployment's all-types SEBI pack to be
incomplete until #3459 settles what the two unserved sections do next. -
Boot-requirement change:
AXONFLOW_REQUIRE_USER_TOKENis FATAL if set to
something unrecognised (#3476). Accepted values aretrue/1/yesand
false/0/no, case-insensitive, surrounding whitespace ignored. Unset,
empty or whitespace-only is fine and meansfalse. Anything else refuses to
boot rather than guess, because both guesses are invisible afterwards: one
silently disables a security control and the other silently denies every
token-less caller. If you templated this variable, check what your template
renders when the value is absent before upgrading, because a rendered
literal such asnull,noneor an unsubstituted placeholder is a boot
failure that a deployment which never set the variable would not have. This
check runs only where a database is wired. -
New optional knob:
AXONFLOW_REQUIRE_USER_TOKEN_TTL_SECONDS(#3476).
Default 60, clamped to[5, 600]. It sets how long a resolved per-org posture
is cached, and therefore how long a column flip takes to become live
everywhere. Unparseable or non-positive values log a warning and fall back to
60 rather than clamping. A lookup-error outcome is cached separately for at
most 15 seconds regardless of this setting. -
Behaviour change with no schema change:
axonflow_segment_resolution_total
gains aphaselabel (#3473). Any recording rule, dashboard panel or alert
selecting the previous unlabelled series will go permanently empty. Aggregate
the label away, or select the phase you actually mean:
enforcementfor resolutions that decide a verdict,session_authfor the
observability-only one at MCP-server session authentication,previewfor the
portal's policy test.axonflow_segment_policy_fail_closed_totaland its
paired "DENYING" log line now count only theenforcementphase, so both can
read lower than before; the difference is denials that never happened. -
Behaviour change with no schema change:
POST /api/v1/mcp/check-input
idempotency keys cached before the upgrade will not replay after it (#3447).
The endpoint component of the store key now carries a hash of the calling
principal, so a pre-upgrade entry no longer matches and the request
re-executes, which on this route means it is re-evaluated. Nothing to run,
but an operator query or sweep matchingidempotency_keys.endpoint = 'mcp.check-input'must move to a prefix match. -
Nothing is backfilled for
llm_call_audits.org_id(#3435). Pre-release
rows continue to be pruned on the default retention window wherever their
org_idis NULL or blank. Which rows those are depends on the writer:
the Gateway Mode writer omitted the column entirely, so its historical rows
are NULL; the OpenAI-compatible writer already bound it, so its historical
rows carry either a real organisation or an empty string, and an empty string
is a value no org predicate can claim. Rows written after this release are
pruned on the per-org override where one exists, which is clamped upward to
the regulatory floor and therefore never shortens retention.
The per-change Community and Enterprise sections, the CI/Testing notes and the
full history are in CHANGELOG.md.
Full release notes: https://github.com/getaxonflow/axonflow/blob/main/CHANGELOG.md