Skip to content

v10.0.0 - segment enforcement reaches five more planes

Choose a tag to compare

@saurabhjain1592 saurabhjain1592 released this 26 Aug 11:03
· 10 commits to main since this release
2722390

[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/160 through core/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:

  1. On the four MCP REST routes, an enterprise caller presenting a per-user
    token that FAILS to validate is now 401 instead 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.

  2. Migrations core/161 and core/162 rewrite audit_logs whole-table, and
    a statement_timeout too tight for them is a BOOT LOOP rather than a
    skipped step, because the migration runner answers a migration error with
    a fatal exit. Size the timeout against your own audit_logs row count
    before upgrading.

  3. The legacy POST /api/v1/sebi/audit/export gains a partial status and
    omits compliance_score when 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, not partial (Enterprise). The
    POST /api/v1/compliance/reports facade's status vocabulary is UNCHANGED
    and never returns partial; on that route completeness is visible only
    inside the document and in the portal's completeness caveat.

  4. A deployment running axonflow_app_role with no
    AXONFLOW_DB_PLATFORM_ADMIN_URL does 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.

  5. Human-in-the-Loop approvals become Enterprise-only. Evaluation was
    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 by tenant_id (#3490).
    tenant_id is 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.sh checks 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)
  • A policy row with no resolvable organisation stops being selectable
    (#3490, migration core/165).
    org_id becomes NOT NULL and non-empty on
    static_policies, dynamic_policies and policy_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 an X-Org-ID header (#3490).
    POST /api/v1/templates/{id}/apply
    (which answered 201), POST /api/v1/policies/import,
    POST /api/v1/dynamic-policies/import, POST /api/v1/policies/simulate,
    /api/v1/policies/impact-report, /api/v1/policies/conflicts and 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, return 201, 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_id column is dropped from the policy tables
    (#3334, migration core/166).
    static_policies, dynamic_policies and
    policy_overrides lose it. It was the pre-v9 organisation key, superseded by
    org_id, and core/165 resolves 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-server check_policy /
    check_output tools (#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_token has no gate point there
    ; read the Security section
    before relying on any of the three.
    (Enterprise)
  • X-User-Token becomes required on the MCP-server check_policy /
    check_output tools for any organisation holding an enabled segment-scoped
    policy for that phase (#3430). (Enterprise)
  • A per-user token that FAILS to validate is now 401 on 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_token lets an organisation refuse a token-less enterprise
    caller at authentication
    , on six gate points, with migration core/163
    and AXONFLOW_REQUIRE_USER_TOKEN (#3476). Opt-in, default off; an
    unparseable env value is a BOOT FATAL. (Enterprise)
  • axonflow_segment_resolution_total gains a phase label (#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-input idempotency entries are scoped to the
    calling principal
    (#3447), so the idempotency_keys.endpoint value changes
    shape and keys cached before the upgrade no longer replay. (Enterprise)
  • Audit attribution on POST /api/v1/mcp/check-input and
    POST /api/v1/mcp/check-output now names the validated per-user identity, not
    a trusted X-User-Email
    (#3447), so rows for token-bearing callers under
    AXONFLOW_TRUST_IDENTITY_HEADERS will 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-200 execution-read shapes now fail (#3367). A
    /api/v1/unified/executions list carrying neither a tenant nor an org key is
    401; a portal org-wide list under axonflow_app_role with no BYPASSRLS
    admin pool is 500. 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 DynamicPolicyEngine is deleted and boot no longer falls back
    to it
    (#3319). AXONFLOW_DEBUG_POLICIES is removed entirely, and
    axonflow_policy_condition_unevaluable_total no longer emits
    plane="memory". (Community)
  • avg_latency_ms is now nullable on POST /api/v1/audit/report and
    POST /api/v1/audit/summary, and on GET /api/v1/audit/session-summary
    (#3424, #3436). (Community / Enterprise)
  • tokens_used, cost and response_time_ms are OMITTED rather than sent as
    0
    on POST /api/v1/audit/search, POST /api/v1/audit/export and
    GET /api/v1/audit/{id}, and the CSV export writes an empty cell (#3424,
    #3427). (Community)
  • POST /api/v1/audit/report returns 500 where it previously returned a
    silently short top-policies table (#3426). (Community)
  • The legacy POST /api/v1/sebi/audit/export gains status: "partial" and
    omits summary.compliance_score
    when 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 returns completed with a score; a request whose sections ALL
    failed is failed, not partial. The POST /api/v1/compliance/reports
    facade's status vocabulary is unchanged and never returns partial.
    (Enterprise)
  • The declarative workflow engine's run ids change prefix from wf_ to
    wfe_
    on POST /api/v1/workflows/execute (#3442). (Community)
  • Two seeded dynamic policies begin evaluating on a real signal for the first
    time since January
    , and migration core/160 deletes the never-tuned
    duplicate that would otherwise have started blocking (#3321). (Community)
  • GET /api/v1/usage and GET /api/v1/usage/summary return null latency
    where they previously returned 0, 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, Premium and - newly - Evaluation. An Evaluation
    deployment using WCP step-gate approvals has them today and will not after
    upgrading.
    The step is still held, with approval_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_history trail 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)

    Evaluation deployments lose Human-in-the-Loop approvals. The entitled
    set becomes Professional, Enterprise and Enterprise Plus. Community,
    Free, Pro, Premium and now Evaluation are 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. A require_approval step 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/queue and 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 entry
    

    Before upgrading, find out whether any of your tenants is relying on it.

    Run this as the table owner or axonflow_platform_admin.
    hitl_approval_queue is ENABLE ROW LEVEL SECURITY (mig core/025) with
    USING (org_id = get_current_org_id()), and get_current_org_id() reads
    current_setting('app.current_org_id', TRUE), which is NULL when unset.
    org_id = NULL is NULL, so on axonflow_app_role with 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
    with SET 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: Professional licensees. GetTierLimits maps Professional
    onto EnterpriseLimits, so it is entitled - a point worth stating because
    the pre-existing IsEnterpriseTier predicate 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
    EnterpriseLimits with MaxPendingApprovals: -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-check passed a hardcoded empty segment set into the
    shared engine, which excludes every segment-scoped static_policies row, so a
    policy that blocked a member on /api/request was 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 is Approved: 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 HTTP 200 carrying a deny verdict, not
    an HTTP error, so a client must key on approved and on the reserved
    segment_resolution_failed identifier, 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
    (NewIdentityAttributeResolver returns 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/process and MAP. The gate now carries the organisation
    and the caller email through to evaluation. A segment-resolution failure
    returns decision: "block"
    with policy_ids: ["segment_resolution_failed"]
    and the reason segment resolution unavailable - request denied (fail-closed, ADR-060 #2989 P3b), again as an HTTP 200 body. 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-Email is 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 gained orgID, email parameters
    (ResumeFromLastCheckpoint, ResumeFromCheckpoint).

  • Governance segments are enforced on the MCP-server plane, with two distinct
    refusals, and X-User-Token becomes required for some organisations
    (#3430).
    (Enterprise)
    The check_policy and check_output MCP-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_failed is 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 the blocked_by identifier
      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_unresolved IS 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_unresolved sites use a hyphen. Match on blocked_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, because check_output governs 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 200 JSON-RPC result carrying allowed: false,
    block_reason, decision_id and blocked_by, and both write a canonical
    blocked audit_logs row. 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-Email is explicitly
    refused as a substitute even under AXONFLOW_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.
    The staticEvaluationWillRun short circuit is consumed
    only inside segmentScopedPoliciesInScope
    (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
    conditional segment_identity_unresolved arm 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-output and
    POST /api/v1/decide each passed an unconditional empty segment set into
    policy evaluation, so every segment-scoped static_policies row 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
    403 and the reserved guard id segment_resolution_failed, and writes a
    canonical blocked audit_logs row. 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 on require_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 email claim, never on
    X-User-Email
    , even under AXONFLOW_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-policies gains a segment_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 ANY ResolveUser failure 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 by jti was silently discarded and
    the request was served as <client-id>@axonflow.local with Role: "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 to require_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/decide already 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 from user_token_required below. Never
    collapse the two.
    They have opposite operator remedies: user_token_rejected
    means a caller's token is bad, and user_token_required means a caller sent
    none where the organisation requires one. Query them with JSONB containment
    (policy_details->'policy_ids' @> '["user_token_rejected"]'::jsonb), never
    with LIKE: _ 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, migration core/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 a true default just as it
    can opt IN over a false one):

    • organizations.require_user_token, BOOLEAN NOT NULL DEFAULT false,
      added by migration core/163. No backfill.
    • AXONFLOW_REQUIRE_USER_TOKEN, the deployment-wide default. Accepted values
      are true / 1 / yes and false / 0 / no, case-insensitive and
      surrounding whitespace ignored. Unset means false.

    A set-but-unrecognised AXONFLOW_REQUIRE_USER_TOKEN is a BOOT FATAL, not a
    warning.
    True!, enabled or yes please refuses 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
    with 401 and an audited user_token_required: POST /api/v1/decide,
    POST /mcp/resources/query, POST /mcp/tools/execute,
    POST /api/v1/mcp/check-input and POST /api/v1/mcp/check-output. The sixth
    is the MCP-server JSON-RPC plane, which refuses at session authentication with
    401 and a WWW-Authenticate: Basic challenge. On that plane the refusal
    is NOT audited under user_token_required.
    initialize, tools/list and
    ping write no audit_logs row for an authentication failure at all;
    tools/call does write one, but under the reserved unauthenticated tenant
    sentinel with policy_ids: ["unauthenticated"], not under the caller's tenant
    and not under user_token_required, because the row is written before any
    caller identity is established. Do not expect a user_token_required row from
    this plane. tools/call also 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 the initialize handshake 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/completions is
    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_token does not reach
    that plane. See the Security section.

    Posture changes are cached and are not instantaneous.
    AXONFLOW_REQUIRE_USER_TOKEN_TTL_SECONDS controls 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 to false under 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_total gains a phase label (#3473).
    (Enterprise)
    The counter previously carried result alone, 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. A phase label now
    separates them, with values session_auth, enforcement and preview.
    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_seconds is 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_total and 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 returns 200 and
    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-input idempotency 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 literal mcp.check-input to
    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.endpoint is a plain text column read by operators and
    sweeps, and has no business holding an address.

    Two consequences. An Idempotency-Key cached 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 one Idempotency-Key now 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-Email in
    audit attribution (#3447).
    (Enterprise)
    On POST /api/v1/mcp/check-input and POST /api/v1/mcp/check-output the
    trusted X-User-Email header won unconditionally, so under
    AXONFLOW_TRUST_IDENTITY_HEADERS a 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. On POST /api/v1/decide the displaced header value survives as
    a claim at policy_details->>'attempted_user_email'; on these two routes it
    is not recorded.

  • A /api/v1/unified/executions list with neither a tenant nor an org key is
    now 401, and an org-wide list with no BYPASSRLS admin pool is now 500
    (#3367).
    (Community / Enterprise)
    The first shape used to fall through to an unscoped WHERE 1=1 read of every
    organisation's executions and return 200. It now returns 401 UNAUTHORIZED
    with Tenant 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 with 500 INTERNAL_ERROR when
    axonflow_app_role is 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.
    initializeComponents calls the
    platform-admin boot guard unconditionally, and the guard fires on exactly this
    configuration, because an UNSET AXONFLOW_DB_USE_APP_ROLE counts as ENABLED
    (only false, FALSE, False or 0 disable 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). The 500
    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
    further 500 arms 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 DynamicPolicyEngine is deleted; boot no longer falls back to
    it, and AXONFLOW_DEBUG_POLICIES is removed (#3319).
    (Community)
    DatabaseDynamicPolicyEngine is 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_POLICIES no longer exists: it only ever
    controlled the deleted engine's verbose logging, and setting it now does
    nothing. The plane="memory" label value of
    axonflow_policy_condition_unevaluable_total is no longer emitted
    ; the
    remaining values (database, mcp, policy_test) are unchanged, so a
    dashboard or alert matching on plane="memory" will go permanently empty.

  • avg_latency_ms is nullable on the audit report and audit summary, and the
    mean is taken over measured rows only (#3424).
    (Community)
    POST /api/v1/audit/report and POST /api/v1/audit/summary used to compute
    COALESCE(AVG(response_time_ms), 0) and return a non-nullable float, so a
    window with nothing measured reported a confident 0ms. Both now select the
    real average and a sample count, and return an explicit JSON null when there
    is nothing to average, alongside a new latency_sample_count. On
    /audit/report the 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/summary is additive there because the property was
    previously undocumented. A client binding avg_latency_ms to a non-optional
    float will break. The measured predicate also relaxed from
    response_time_ms > 0 to response_time_ms IS NOT NULL, so a genuine
    sub-millisecond decision is now a sample rather than a discarded row.

  • tokens_used, cost and response_time_ms are omitted rather than sent as
    0, and the CSV writes an empty cell (#3424, #3427).
    (Community)
    On POST /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 with omitempty, so the key is absent on
    a row with nothing recorded and present with its real value otherwise. In the
    CSV, the tokens and response_time_ms cells are empty rather than 0,
    which is what stops a spreadsheet AVERAGE() from counting them. A genuine
    measured 0 still renders as 0. A consumer that indexes these keys
    unconditionally, or that treats a missing key as an error, must handle
    absence.

  • POST /api/v1/audit/report returns 500 where 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
    sibling POST /api/v1/audit/summary deliberately does NOT fail: it degrades
    with a new top_policies_unavailable flag instead, because a summary tile
    going quiet is not the same as a compliance report going short.

  • The legacy SEBI export gains status: "partial" and omits
    summary.compliance_score on an incomplete export (#3435).
    (Enterprise)
    See the Enterprise section for the full item. This applies to
    POST /api/v1/sebi/audit/export only.
    In contract terms, on that route:
    compliance_score is now a nullable field that is ABSENT rather than null
    or 0 whenever a section could not be produced, status gains partial
    alongside completed and failed, and summary.report_state plus
    summary.sections are the fields a caller must read instead. An
    integration on that route keying on status === "completed", or binding
    compliance_score to a non-optional float, breaks on upgrade.
    The
    nullability is recorded in .github/oasdiff-err-ignore.txt with the
    measurement behind it.

    POST /api/v1/compliance/reports is a different contract and does not
    change.
    Its status vocabulary is pending / processing / completed /
    failed (platform/orchestrator/compliancereport/types.go) and gains no
    fifth value, so a facade caller will keep seeing completed and 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_, not wf_ (#3442).
    (Community)
    Ids returned by POST /api/v1/workflows/execute change prefix. That route
    runs a spec handed to it in the request body and appears in workflows,
    workflow_steps and execution_history nowhere at all, so it is a different
    thing from a governed control-plane workflow and no longer shares its prefix.
    The ids are opaque and nothing in the platform parses them, but a caller
    that stored one and matches on the wf_ prefix stops matching
    . Ids minted
    before this release keep their old prefix; no migration, no row rewritten.

  • Two seeded dynamic policies begin evaluating, and a never-tuned duplicate is
    deleted (#3321, migration core/160).
    (Community)
    risk_score is computed by the platform again rather than read out of the
    caller's own request body, so sys_dyn_high_risk_block (risk_score > 0.8, action warn) and sys_dyn_anomalous_access (risk_score > 0.6,
    action alert) start firing on matching requests that previously passed
    unconditionally. Neither newly blocks: both are allow-but-annotate.
    Migration core/160 deletes high_risk_block, a 2025 duplicate of
    sys_dyn_high_risk_block that migration 036's downgrade to warn never
    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/summary and
    GET /api/v1/audit/session-summary return nullable latency (#3436).

    (Enterprise)
    avg_latency_ms and the raw latency_ms become pointers, returning explicit
    null rather than a fabricated 0, with new latency_sample_count and
    latency_bucket_count companions on /usage/summary and a
    latency_sample_count on 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, every claude_code_metric row was
    silently dropped from the response and the CSV behind a 200 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 its sp_private_key,
    its sp_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-authenticated POST /api/v1/sso/config/test. The pre-auth SAML
    routes get a generic body instead. GET /auth/saml/{tenant}/login answers
    400 and the callback answers 401 with "SAML is unavailable for this
    tenant; contact your administrator", and SP metadata answers a generic 500.
    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/test returns 400 with 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.go and hitl_wcp_enterprise.go
    each wrote INSERT INTO hitl_approval_queue directly, 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 tool axonflow_request_approval in v7.8.0 (2026-05-07), and that fix
    shipped without a ratchet. These two were not a later regression:
    git log --diff-filter=A puts both files at 9f19c3531, 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_queue in 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 under scripts/, 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 by tenant_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: ALLOWED
    

    The 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 on tenant_id at 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_id remains 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.sh check 23 reports this shape separately.

    • Override rows do NOT become organisation-wide, deliberately. A
      policy_overrides row 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.sh check 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.
      A policy_overrides row with a NULL tenant_id is 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 required organization_id IS NOT NULL and compared that column
      against the caller's organisation - and organization_id is 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 on org_id now 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
      block to warn downgrade 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 filtered revoked_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_id unset), which were inert
    on the enforcement path before this release and apply now. Check 24 names
    every policy row that migration core/165 will 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 a block to warn
    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 hard block for 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
    with 403 when it arrives without one. That refusal is deliberate:
    evaluating without an organisation would silently drop every tenant-authored
    dynamic policy behind a 200, 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 answer 401 with the code ORG_REQUIRED when the request
    carries no X-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
    resolveOrgOrFail helper so the refusal cannot drift between them. The
    template route is the one whose status changes for a previously working
    caller: it answered 201. Applying a template CREATES a policy, and after this release
    a policy is selected by its organisation, so PolicyRepository.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, return 201, list correctly, and govern nobody. A 401 naming
    the header it wants is the honest answer; a 201 that 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 403 rather than
    400.
    The orchestrator's MCP dynamic-policy evaluation route refuses a
    header-less internal-service caller that sends tenant_id but no
    organization_id. The status is chosen, not incidental: the agent's
    EvaluateWithGracefulDegradation absorbs a 400 by degrading to allow-all
    and refuses to absorb 401 or 403 (#3068). A 400 here 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/simulate forced the caller's tenant from the
    gateway-stamped header onto both User and Client, but left
    client.org_id exactly as the request body sent it. That field is not inert:
    the dynamic-policy evaluator resolves client.org_id (and its bare org_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 both User.OrgID and
    Client.OrgID from the authenticated request, and refuses with
    401 ORG_REQUIRED when 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,
    through ListActivePoliciesForTenant, and the tenant was already forced from
    the gateway header before evaluation; the policy_metrics row 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-report and
    /api/v1/policies/conflicts were never exposed to this, for a duller reason
    than a better-resolved scope: neither builds a Client block at all, so
    neither has a body-supplied organisation to validate.

  • A policy row with no resolvable organisation stops being selectable
    (#3490, migration core/165).
    (Community / Enterprise)

    core/165 makes org_id NOT NULL with a non-empty CHECK on
    static_policies, dynamic_policies and policy_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 global wildcard;
    then, on dynamic_policies only, rows with no tenant at all are mapped to
    global, because on that table an absent tenant is the apply-to-everyone
    shape; then the tenants mapping, the only step that can move a row from one
    organisation to another; then the legacy org_id == tenant_id collapse; then,
    on policy_overrides only, the organizations lookup 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 joins organizations.id::text against
    policy_overrides.organization_id::text, and those were never the same kind
    of value: organizations.id is SERIAL, an integer, while
    organization_id was declared UUID and stayed UUID until core/133
    retyped it to text. 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.sh check 24 reports the same rows read-only beforehand, using
    the migration's own resolution chain, so the two agree.

    Decide per row: UPDATE org_id with 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_id column is dropped from the policy tables
    (#3334, migration core/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_overrides carried
    core/030's valid_override_scope CHECK,
    (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_id NULL, tenant_id NULL, org_id set - was REJECTED on
    insert. Since core/133 the 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/166 drops 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's NOT NULL on
    org_id covers what it was for, unconditionally and on all three tables.

    static_policies, dynamic_policies and policy_overrides lose
    organization_id. It was the pre-v9 organisation key, superseded by org_id
    and carried since only for the rows that had not been migrated off it -
    which is why core/166 runs after core/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 to org_id, which carries the same meaning and is now
    guaranteed non-empty.

    No API field is removed, and an integrator should not assume otherwise.
    EffectivePolicies and its response type keep organization_id
    (platform/agent/policy_types.go:215), as do DynamicPolicyRequest and 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_id was 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/executions list carrying neither a tenant
    nor an org key ran FROM execution_history WHERE 1=1 across every
    organisation and answered 200. It is now 401, 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 and POST /api/v1/decide, a segment-scoped policy that blocked a
    member on /api/request was 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_token does 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 is block
    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 why require_user_token has nothing to
    change there. The WCP HTTP step-gate IS exposed to it, and
    require_user_token does 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-supplied X-User-Email under the
    default-off trust gate), but it can be OMITTED: a governed caller that sends
    no X-User-Token reaches 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/completions at the edge. Nothing in this
      release changes that route.
    • Keep gating the response phase of POST /api/v1/process at 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, and require_user_token has
      no gate point on that plane.
    • Do NOT drop an edge gate on the MCP REST routes or /api/v1/decide on
      the strength of this release alone.
      Turn on require_user_token for 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/process response phase and the WCP step-gate, which none of
      the above does.
  • 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 on check_policy and check_output when 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 existing axonflow_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
    resolveMCPServerSegmentsForPolicy proceeds 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, so jti
    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_token makes 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_rejected so 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 than LIKE.

    New audit field attempted_user_email on POST /api/v1/decide, at
    policy_details->>'attempted_user_email'. It records an X-User-Email a
    caller asserted that did NOT become the row's attribution, in the same
    attempted-versus-actual shape as the existing attempted_tenant_id and
    attempted_org_id. The user_email column 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's org_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_id claim, 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 an org_id matching 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/process and 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_id has 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 stamp org_id from
    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 by org_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/160 through core/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. Only 161 and 162 carry a sizing
    risk; see the joint operator note below. 163, 164 and 166 are cheap and
    none of them rewrites a table. 165 takes ACCESS EXCLUSIVE on 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/165 makes the organisation key mandatory on the policy tables, and
    a row it cannot resolve one for stops firing.
    static_policies,
    dynamic_policies and policy_overrides get NOT NULL plus a non-empty
    CHECK on org_id, because after #3490 that column is the only thing that
    selects a policy row. The migration resolves what it can - the global
    wildcard; then, on dynamic_policies only, rows with no tenant at all, which
    map to global rather than to the sentinel because on that table an absent
    tenant is the apply-to-every-tenant shape; then your tenants mapping; then
    the legacy org_id == tenant_id collapse; then the organizations lookup
    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
    WARNING naming those rows, but it raises it during the upgrade in the
    agent's boot log; ./preflight.sh check 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/166 drops the legacy organization_id column 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 a WARNING naming 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 good org_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/167 adds 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 (approved versus pending) and cannot share an index - and
    hitl_approval_history's action CHECK widened to admit consumed (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_queue from the ADD COLUMN, and
    hitl_approval_history from 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 added NOT 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 no consumed row 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/160 deletes one seeded policy row. It removes high_risk_block,
    seeded by migration core/010 (2025-11-20) and superseded by the
    near-duplicate sys_dyn_high_risk_block that migration core/031
    (2025-12-24) seeded later under the same name, conditions, priority and
    tenant. high_risk_block is the older of the two, and it is the one deleted:
    migration 036's block to warn downgrade tuned only the sys_dyn_ row,
    which is also the id platform/agent/detection_config.go treats as
    canonical. With risk_score now computed (#3321), the
    never-tuned duplicate would have started BLOCKING production traffic on
    upgrade while its intentionally-tuned twin sat at warn right beside it. A
    paired down-migration restores it verbatim. The threshold this policy pair
    enforces lives in sys_dyn_high_risk_block's conditions JSON and is tunable
    through the policy API and the portal like any other dynamic policy; it is NOT
    the dynamic_policies.risk_threshold column both rows also carry, which is
    read by nothing in this codebase.

  • core/161 and core/162 are WHOLE-TABLE backfills of audit_logs.
    161 sets response_time_ms = NULL on every historical row holding a
    fabricated 0; 162 sets tokens_used and cost to NULL on every
    historical row where both coalesce to zero, at least one of the two is not
    already NULL
    (the limb is tokens_used IS NOT NULL OR cost IS NOT NULL, an
    OR, so a row with one NULL and one fabricated 0 still matches), and the row
    carries no provider and no model. Both are one statement in one transaction,
    so each takes ROW EXCLUSIVE on audit_logs for 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 pin SET 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
    timestamp is a predicate column in both statements, but timestamp < NOW()
    selects essentially the whole table, so the planner seq-scans regardless; the
    remaining predicate columns carry no index at all.

    162 matched fewer rows than 161 when 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, 161 matched 140 rows and 162 matched 100 of those same rows,
    with none matching 162 alone. The predicates do not imply the relation.
    162 never references response_time_ms, so it cannot entail 161's
    predicate; what the headers argue is the narrower point that 162's extra
    guards exclude writers 161 catches, such as the HITL approval writer, which
    binds a literal 0 into response_time_ms and names neither usage column.
    161 was 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/163 adds 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 COLUMN with 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_catalog rather than information_schema, which is privilege-filtered and
    would let a role without a privilege on organizations read "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 the require_user_token item 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/164 widens organizations.license_key from VARCHAR(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-customer then answered 500 with 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 makes TEXT the honest type. The look-alike columns that store
    fixed-length hashes are correct at VARCHAR(512) and are untouched.

    Its down-migration is a deliberate no-op, matching the convention 161
    and 162 set. Narrowing back to VARCHAR(512) would either fail outright or,
    with a USING clause, 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_timeout on a large audit_logs is
    a BOOT LOOP, not a skipped step.
    The migration runner answers a migration
    error with a fatal exit, so a 161 or 162 that trips a per-session or
    per-role statement_timeout will fail the container, which will restart, and
    fail again. Size the timeout against your own audit_logs row 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
    use idx_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_timeout against the larger of the two match counts and
    against a full-table scan. If audit_logs is large, take an actual timing on
    a restored copy rather than extrapolating; a statement_timeout that already
    suits a full-table scan of it will suit these two.

  • Do NOT re-run 161 by 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.
    161 was 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 recorded 161 will not re-run the edited
    file. This is harmless here, because 161 is 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)
    and sys_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, a select * query +0.3) before rolling out.

  • Boot-requirement change: AXONFLOW_DEBUG_POLICIES no 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_URL becomes
    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 a 500 on 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 UNSET AXONFLOW_DB_USE_APP_ROLE is)
    and the admin URL is blank. So the 500 arm 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 gains partial; 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-optional compliance_score, and
    handle failed as well as partial. On the
    POST /api/v1/compliance/reports facade there is no status change to react
    to
    : it still reports completed for 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_TOKEN is FATAL if set to
    something unrecognised (#3476).
    Accepted values are true / 1 / yes and
    false / 0 / no, case-insensitive, surrounding whitespace ignored. Unset,
    empty or whitespace-only is fine and means false. 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 as null, none or 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 a phase label (#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:
    enforcement for resolutions that decide a verdict, session_auth for the
    observability-only one at MCP-server session authentication, preview for the
    portal's policy test. axonflow_segment_policy_fail_closed_total and its
    paired "DENYING" log line now count only the enforcement phase, 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 matching idempotency_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_id is 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