Skip to main content

Identity Compatibility Mode

Platform version: v10.2.0 (introduced in v10.2.0; ships dark, mode off by default). Applies to: all deployment modes, on both the agent and the orchestrator.

What this covers

v10.2.0 carries the ADR-065 identity plane: organization-scoped trust realms, canonical realm-qualified principals, ordered actor chains and a normalized directory graph. None of that changes how a request is authenticated. What connects it to live traffic is a set of compatibility adapters that take what one of the four legacy credential paths already decided and run the same credential through realm verification, producing an independent second opinion about the same request.

Legacy pathWhere it is adaptedBuilt-in realm
api_credential (Ed25519 license key, bcrypt API key, community-SaaS registration secret, and the internal-service HMAC hop)agent authenticationaxonflow-api-credential, axonflow-internal-service, axonflow-community
hs256 (AxonFlow-minted per-user tokens: user_token / X-User-Token)the agent's per-user token path and the fleet choke pointaxonflow-minted
oidc (the tenant's configured OIDC issuer, Enterprise)the same fleet choke pointoidc (one per organization)
trusted_header (X-User-ID / X-User-Email behind AXONFLOW_TRUST_IDENTITY_HEADERS=true)the agent's MCP-server session identity; the orchestrator's principal binding (records only, see below)axonflow-trusted-header

The built-in realms declare what the legacy path already enforces, plus the two invariants a trust realm structurally requires (an expiry is required; an audience is bounded), and nothing else. That is deliberate: a realm declaring an audience the platform never checked, or an assurance floor nobody attested, would make every request diverge and bury the findings that matter.

The mode switch

The mode has two inputs and one rule. The deployment declares a default with the environment variable below; an organization may override it with an identity settings record. A record wins, in both directions: an organization can be observed on a deployment whose flag is unset, and exempted on one running enforce. An organization with no record, or a record leaving the mode unset, follows the deployment. Everything the rest of this page says about a mode applies to whichever mode a given organization resolves to.

The record is written through the admin API documented at Identity Plane APIs; a change is honoured within a TTL (AXONFLOW_IDENTITY_ORG_SETTINGS_TTL_SECONDS, default 60s). If the settings row cannot be read, the deployment's declaration is used rather than a guess at the organization's, and the failure is counted. Per-organization records are Enterprise; a Community deployment has no such table and the environment variable is the whole answer.

# Agent AND orchestrator environment. Default: unset, which is off.
AXONFLOW_IDENTITY_COMPAT_MODE=off # identity-plane verification does not run
AXONFLOW_IDENTITY_COMPAT_MODE=shadow # runs, and is RECORDED ONLY
AXONFLOW_IDENTITY_COMPAT_MODE=enforce # runs, records, and REFUSES what the identity plane does not admit

Parse semantics, and how they differ from AXONFLOW_TRUST_IDENTITY_HEADERS:

  • unset or empty: off. This is the only spelling of "off by omission".
  • off (also accepted: false, 0, disabled), shadow, enforce, case-insensitive and whitespace trimmed: that mode.
  • anything else is FATAL at boot. The process logs the value and exits. The trust gate treats a typo as false with a warning, because its safe direction is "ignore the header". This flag has no safe direction to fall back to: off would leave an operator who typed enfore believing their deployment enforces, and enforce would take authentication down on a typo. A deployment that will not start is the one failure an operator notices immediately.

Set it on both services. The agent binds a caller's principal from every credential path; the orchestrator binds one from the trust-gated identity headers (and notes at boot whether a SCIM-backed directory is wired, so the built-in realms declare the directory source the deployment actually has). A value on one and not the other is exactly the "consulted in some planes and not others" split the adapter exists to prevent. The shipped docker-compose.yml and its enterprise and scaled overlays carry all three variables on both services; the CloudFormation stack templates have no parameter for them yet, so on an ECS deployment the switch cannot be set until that parameter exists.

Companion variables

# Narrows what `enforce` refuses to a comma-separated allow-list of reason codes.
# Empty (default) means every reason. It can only NARROW: a reason not on the
# list is recorded exactly as it would be in shadow. An unrecognized code is
# fatal at boot, like an unrecognized mode, and it is parsed in EVERY mode, so
# a typo here stops the process even with the mode unset.
AXONFLOW_IDENTITY_COMPAT_ENFORCE_REASONS=UNKNOWN_REALM,ORG_BINDING_MISMATCH

# How many AGREEMENTS pass between sampled agreement log lines. Default 100000.
# Set it to 1 while reading a shadow phase, so "the adapter ran on this path and
# agreed" is distinguishable from "the adapter never ran"; 0 disables agreement
# lines entirely (the counters still move). An unparseable value falls back to
# the default with a warning; it is never fatal, because it decides how chatty
# a log line is and not whether authentication changes.
AXONFLOW_IDENTITY_COMPAT_AGREEMENT_LOG_EVERY=1

The reason allow-list is one of the two staged-rollout axes; the other is the organization, set through the identity settings admin API. The divergences this plane surfaces are per-(organization, path) constants rather than tail events: a plugin fleet asserting only an email produces SUBJECT_MISSING on every request it makes, and an organization with no enabled SSO row produces UNKNOWN_REALM on all of its token traffic. Enforce the reasons you have driven to zero in shadow; keep recording the rest.

The reason allow-list is not per organization: it is process-wide and applies wherever the resolved mode is enforce. So the two axes compose as "which organizations are enforcing" and "which reason codes enforce anywhere", not as a per-tenant matrix.

Behavior per mode

off (default)shadowenforce
Legacy authentication decides the requestyesyesyes, and the identity plane may additionally refuse
Identity-plane verification runsno. The adapter returns before consulting the registry or the recorder, and nothing it produces is observed, stored, forwarded or loggedyesyes
Counterfactual recordednothingevery evaluation (agreements sampled, divergences always)every evaluation, plus enforced=true on applied refusals
What a caller seeslegacy behavior, unchangedlegacy behavior: the authentication outcome (status, error, blocked) is identical to off, request for request, except that on the Enterprise outage legs a 401 body carries the outage wording (see the divergences to expect) where off carries v10.1.0's bytesa 401 (a 403 on the audit-verification read authority) where the identity plane does not admit a credential legacy accepted; renderings under What enforce refuses
Orchestrator trusted-header planerecords nothingrecordsrecords and never acts (see below)

Two invariants hold in every mode:

  1. The adapter can only ever refuse. It has no path that turns a legacy rejection into an acceptance. A credential the legacy path rejected reaches realm verification with signature_verified=false and is denied for exactly that reason, so "the identity plane admitted what legacy rejected" is unreachable rather than unlikely, and it carries its own ALARM log line if it ever fires. The worst a misconfigured enforce deployment can do is deny requests legacy would have served.
  2. No call site reads the mode. Every enforcing call site is one line, and the refusal is constructed in exactly one function under the mode check. A call site cannot forget the mode because there is no mode for it to forget. The package itself reads the mode in exactly two places, by design: the resolver that owns admission, and the gate that selects the wording of the outage errors below, which decides no admission - every branch it selects between rejects the credential, in every mode.

Why the orchestrator plane never enforces. Clearing the actor on a refusal looks fail-closed and is not: a shipped default policy carries a user.role equals "evaluation" condition that raises risk, so clearing the role stops that modifier applying and the request is scored lower. It would also destroy a role established by the validated-token channel over a refusal about the header one. The credential that plane sees is enforced where it is authenticated, on the agent.

What shadow records

The shadow phase's only product is the counterfactual record. It is written to the process log under the [IDENTITY-COMPAT] prefix. The recorder keeps counters behind it (by divergence, and divergences by path), but nothing in either process exposes them yet: no metrics exporter ships in v10.2.0 (see Not covered), so the log is the surface to read.

At startup, always:

[IDENTITY-COMPAT] agent: AXONFLOW_IDENTITY_COMPAT_MODE=shadow (identity-plane verification runs and is RECORDED ONLY; legacy authentication decides every request)

Per divergence, per indeterminate outcome, and per enforced refusal, always:

[IDENTITY-COMPAT] component=agent mode=shadow path=hs256 org=acme divergence=identity_refused legacy=accepted legacy_reason="" identity=DENY/UNKNOWN_REALM detail="issuer \"https://idp.example\" has no declared trust realm in this organization; a validly signed credential is not a declared one" realm= principal= epoch=0 enforced=false
FieldMeaning
componentwhich binary recorded it (agent, orchestrator)
modethe mode the process was running in
paththe legacy path: api_credential, hs256, oidc, trusted_header
orgthe authenticated organization
divergencenone (agreement); identity_refused (legacy accepted, identity plane denied); identity_indeterminate (legacy accepted, identity plane could not tell); identity_admitted_legacy_rejected (the unreachable direction, always accompanied by an ALARM line); adapter_defect (the adapter was handed input it could not evaluate); not_evaluated
legacy / legacy_reasonwhat the legacy path decided and, on a refusal, why. Never credential material
identitythe identity plane's admission state and reason code
detailthe field that makes the record actionable: the issuer that has no realm, the claim that was absent, the audience that did not intersect. Never surfaced to a caller
realm / principal / epochthe realm and canonical principal the identity plane resolved, and the realm registry's epoch at the time
enforcedwhether this record was applied as a refusal (enforce only)

Agreements are counted and logged once per AXONFLOW_IDENTITY_COMPAT_AGREEMENT_LOG_EVERY. Divergences are never sampled: the classes below are per-(organization, path) constants rather than tail events, so an organization whose token traffic all diverges on one class (every request from a fleet asserting only an email, say) writes one line per request until that class is cleared. Read the expected volume before enabling shadow on a high-traffic deployment. An indeterminate outcome is logged individually even when the two planes agree, because "your IdP is unreachable" and "your revocation store is down" both arrive as agreements and are the operationally sharpest records this plane produces.

Every caller-influenced field is sanitized before it reaches the log, so a newline in an asserted header cannot inject lines.

The divergences to expect

Each of these is a real property of the legacy paths:

ReasonWhat it meansWhere it comes from
UNKNOWN_REALMthe credential's issuer is declared by no realmthe legacy per-user token path does not require an issuer the deployment has declared. This is the single largest expected divergence and the whole point of the plane; declare a realm for every issuer you mint or accept
ORG_BINDING_MISMATCHthe token asserts an org_id other than the credential'sthe legacy path reads the organization claim without binding it to the credential that authenticated; the identity plane binds the two
REVOCATION_UNAVAILABLEa realm declares a revocation source and the credential carries no revocation key (jti), or the revocation lookup itself failedthe agent's per-user token path treats a jti-less token as unrevocable-and-therefore-fine (the fleet validator already requires jti); the identity plane calls it indeterminate
SUBJECT_MISSINGthe trusted-header upstream asserted only X-User-Emailan email is an alias, never an identifier. A trusted-header deployment that wants a canonical principal has to assert a stable subject (X-User-ID); one that asserts only an address gets attribution, not identity

You will also see indeterminate agreements logged individually, which are not divergences: KEY_MATERIAL_UNAVAILABLE (the verifying key set could not be obtained: JWKS unreachable, or cached keys past the staleness bound). Both planes reject that credential, so the two agree, but it is logged on every occurrence because it is an outage, not a forgery, deliberately distinct from a signature failure so an operator paged for it goes looking at their IdP rather than for forged tokens.

The one caller-visible effect of enabling shadow rides the same distinction, and it is deliberate. With the identity plane running (shadow or enforce), the Enterprise fleet per-user token validators and the portal SSO login verifier word an outage as an outage: a revocation check or JWKS that cannot be consulted says the revocation status or the key material is unavailable, where with the mode unset every one of those errors emits v10.1.0's exact bytes. The status stays 401, the verdict stays a rejection, in every mode; only the wording of an outage rejection changes, so an IdP key rotation stops being reported as an invalid token.

Two further classes are latent rather than live: a token carrying no exp is refused as CREDENTIAL_EXPIRED (both production minters stamp one), and a token minted for a different audience is refused (neither production minter sets aud).

What enforce refuses

Only a credential the legacy path accepted that the identity plane did not admit, for a reason on the allow-list (or any reason, if the list is empty). The refusal is a 401 on every adapted agent path except the audit-verification read authority, which keeps its existing 403. There is no machine-readable refusal code on the wire in v10.2.0: the internal error code is identity_realm_refused, but no renderer emits that field, so what a caller can match on is the message text, and it differs by rendering:

Adapted siteWhat the caller receives
API and proxy middleware401; message Authentication refused by the identity plane (REASON). The credential authenticated, but .... The string identity_realm_refused is NOT in the body
Per-user token path401; message Invalid user token: identity_realm_refused: ...
Fleet choke point via the MCP REST routes401; {"error": "invalid user token: identity_realm_refused: ..."}
MCP-server session401 JSON-RPC error on initialize whose message begins invalid user token: identity_realm_refused: (trusted-header refusals: identity_realm_refused: upstream-asserted identity refused ...); every non-initialize call on a refused session answers the generic Authentication required with no refusal string
Audit-verification read authoritythe existing 403, no string; this site returns a boolean and has no error channel

Previously an identity-plane refusal would have shared invalid_user_token with a tampered signature, so "my realm configuration is wrong" and "someone is forging tokens" were indistinguishable. The message text now distinguishes an identity-plane refusal on four of the five renderings (the API and proxy body says so in words) and carries the identity_realm_refused token on three; a code field on the wire is owed.

"Did not admit" includes two indeterminate outcomes. With an empty allow-list, enforce also refuses on REVOCATION_UNAVAILABLE (a jti-less token on the agent's per-user token path under a realm declaring a revocation source, or the adapter's own revocation lookup failing; the fleet validator already requires jti, so on that path a jti-less token is a legacy rejection and nothing is added) and on IDENTITY_INTERNAL_ERROR (the organization's trust realms could not be established, for instance an unreadable SSO configuration row, for a credential whose issuer is not already declared; or a realm re-declared mid-verification). KEY_MATERIAL_UNAVAILABLE is accepted in the allow-list but inert there: a JWKS outage is already a legacy rejection, and the adapter never refuses what legacy refused. This is the reason to list the reasons you enforce rather than enabling enforce bare: an operator who wants realm and organization binding enforced while jti-less tokens and realm-source outages keep being recorded lists UNKNOWN_REALM,ORG_BINDING_MISMATCH and leaves the two indeterminate codes off.

How much to worry about turning it on

shadow changes no status, no verdict and no authentication outcome, and is safe to enable on any deployment whose log volume can absorb it; the runtime suite for this feature proves the authentication outcome (status, error, blocked) is identical to off, request for request, across assertions none of which induces an outage. The one thing a caller can observe change in shadow is the outage wording described under What shadow records: an Enterprise 401 for a revocation or key-material outage carries the reclassified message where off carries v10.1.0's bytes. enforce can only deny, so the risk of enabling it is availability, not exposure: every request the identity plane refuses is one the legacy path would have served. Run shadow first, read the divergence records, fix or accept each class, and enable enforce with the reasons you have cleared listed in AXONFLOW_IDENTITY_COMPAT_ENFORCE_REASONS.

What enforce will mean at v11

The ADR-065 release plan cuts every enforcement plane over to the new policy decision point in one major release, v11.0.0, with default deny and a time-bound compatibility profile. For the identity plane that means:

  • the canonical (realm, subject) principal becomes the identity every decision is evaluated against, and the legacy per-user token path stops being an independent authority;
  • the shadow-observed divergences above must be at zero unexplained for the agreed observation window before the cutover;
  • a deployment arriving at v11 from v10.x without having run shadow receives the compatibility profile window rather than silent breakage.

Nothing in v10.2.x cuts a plane over. The mode switch is how an operator prepares: enable shadow during v10.2.x, drive the divergence classes to zero, then enable enforce with the reason allow-list ahead of v11. The compatibility adapters exist only to bridge the legacy credential paths; the release plan retires the legacy evaluators and compatibility profiles at v12.0.0, and the adapters have no purpose past that point.

Not covered in v10.2.0

Named here so the gap is visible rather than assumed closed:

  • The customer-portal authentication surfaces (OIDC and SAML login, SCIM, the portal middleware): no realm verification runs there. The reasons to defer are that the portal is its own Go module with its own regression surface, and that SAML is a fifth credential class with no credential type yet - not import feasibility: the portal can and does import the shared identity packages. One portal surface is already partially covered: the portal SSO OIDC login verifier classifies a JWKS-unavailable failure as an outage under the same mode gate, so with the plane running an IdP key rotation during login stops being reported to the user as an invalid id_token.
  • Further in-process X-User-Email readers (overrides, explain, the MAP HITL adapter, read scope, reviewer binding, circuit breaker, the static policy API handlers, workflow control). They are downstream of an adapted decision under both postures; consolidating them onto one choke point is its own change.
  • A per-organization reason allow-list. The organization and the reason list are separate axes: a record selects an organization's mode, and AXONFLOW_IDENTITY_COMPAT_ENFORCE_REASONS narrows what enforce refuses everywhere it applies. Narrowing reasons for one tenant and not another is not possible.
  • A metrics exporter for the counterfactual recorder. Its counters exist in-process and nothing exports them. (The Shared Signals receiver is separate and does export Prometheus counters; see Identity Plane APIs.)
  • A correlation id joining the four to six records one request produces across two processes.
  • Realm-registry persistence needs a migration the operator allocates. (The Shared Signals / CAEP receiver does ship an HTTP endpoint in v10.2.0, inert until an organization opts in: Identity Plane APIs.)
  • A machine-readable refusal code on the wire.