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 path | Where it is adapted | Built-in realm |
|---|---|---|
api_credential (Ed25519 license key, bcrypt API key, community-SaaS registration secret, and the internal-service HMAC hop) | agent authentication | axonflow-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 point | axonflow-minted |
oidc (the tenant's configured OIDC issuer, Enterprise) | the same fleet choke point | oidc (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
falsewith a warning, because its safe direction is "ignore the header". This flag has no safe direction to fall back to:offwould leave an operator who typedenforebelieving their deployment enforces, andenforcewould 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) | shadow | enforce | |
|---|---|---|---|
| Legacy authentication decides the request | yes | yes | yes, and the identity plane may additionally refuse |
| Identity-plane verification runs | no. The adapter returns before consulting the registry or the recorder, and nothing it produces is observed, stored, forwarded or logged | yes | yes |
| Counterfactual recorded | nothing | every evaluation (agreements sampled, divergences always) | every evaluation, plus enforced=true on applied refusals |
| What a caller sees | legacy behavior, unchanged | legacy 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 bytes | a 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 plane | records nothing | records | records and never acts (see below) |
Two invariants hold in every mode:
- 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=falseand is denied for exactly that reason, so "the identity plane admitted what legacy rejected" is unreachable rather than unlikely, and it carries its ownALARMlog line if it ever fires. The worst a misconfiguredenforcedeployment can do is deny requests legacy would have served. - 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
| Field | Meaning |
|---|---|
component | which binary recorded it (agent, orchestrator) |
mode | the mode the process was running in |
path | the legacy path: api_credential, hs256, oidc, trusted_header |
org | the authenticated organization |
divergence | none (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_reason | what the legacy path decided and, on a refusal, why. Never credential material |
identity | the identity plane's admission state and reason code |
detail | the 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 / epoch | the realm and canonical principal the identity plane resolved, and the realm registry's epoch at the time |
enforced | whether 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:
| Reason | What it means | Where it comes from |
|---|---|---|
UNKNOWN_REALM | the credential's issuer is declared by no realm | the 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_MISMATCH | the token asserts an org_id other than the credential's | the legacy path reads the organization claim without binding it to the credential that authenticated; the identity plane binds the two |
REVOCATION_UNAVAILABLE | a realm declares a revocation source and the credential carries no revocation key (jti), or the revocation lookup itself failed | the 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_MISSING | the trusted-header upstream asserted only X-User-Email | an 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 site | What the caller receives |
|---|---|
| API and proxy middleware | 401; 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 path | 401; message Invalid user token: identity_realm_refused: ... |
| Fleet choke point via the MCP REST routes | 401; {"error": "invalid user token: identity_realm_refused: ..."} |
| MCP-server session | 401 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 authority | the 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
shadowreceives 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-Emailreaders (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_REASONSnarrows whatenforcerefuses 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.
Related
- Per-Developer Identity: the
AXONFLOW_TRUST_IDENTITY_HEADERSgate this flag'strusted_headerpath sits behind, and the per-user tokens thehs256andoidcpaths verify. - v10.2.0 release notes.
