License Matrix — hosting mode × scope
Every AxonFlow license token carries a JWT-standard aud claim from a closed set of six values. The six values describe a 2 × 3 matrix:
| Hosting mode \ Customer scope | Plugin | SDK | Full |
|---|---|---|---|
SaaS (we operate the agent — try.getaxonflow.com) | axonflow.saas.plugin | axonflow.saas.sdk | axonflow.saas.full |
| Self-Hosted (you operate the agent — Docker / In-VPC) | axonflow.self_hosted.plugin | axonflow.self_hosted.sdk | axonflow.self_hosted.full |
Each cell is a quadrant — a distinct product surface with its own pricing, validation context, and feature set. The matrix is closed and architecturally complete from day one. Future product launches add config (which aud to set at issuance, what tier to bind) — not architecture.
This page explains how the matrix maps to actual customer behaviour.
Why the matrix exists
Before this design AxonFlow had a single notion of "license tier" — Community / Evaluation / Professional / Enterprise / EnterprisePlus — and a token's purpose was inferred from its tier name and where it ended up. That worked when there was only one product axis (self-hosted Enterprise tier ladder), but as new products arrived (the SaaS Plugin Pro tier; the future Plugin In-VPC eval product; future SaaS-only SDK / Full products) the inferred-purpose approach started to break down:
- A SaaS Plugin Pro token pasted into
AXONFLOW_LICENSE_KEYon a self-hosted agent would silently fail closed because the agent saw an unknown tier — but the failure mode was opaque. - A self-hosted Enterprise license sent as
X-License-Tokenon the SaaS agent would similarly fail with no clear reason. - A plugin-only-scope token sent on a request to a future SDK-only product surface had no validation distinguishing it from a full-scope token.
The matrix solves this by making the token's purpose explicit in its aud claim instead of inferred from context. Each validation context — SaaS Plugin path, future SaaS SDK path, self-hosted license loader — keeps an explicit accept list of aud values it recognises, and rejects everything else with an explicit reason.
How a request flows through the matrix
When a plugin or SDK calls a Community SaaS endpoint with a Pro-tier license token, the agent's Community-SaaS auth path does this:
- Resolve
tenant_idfrom the Basic-auth credential (existing behaviour —cs_<uuid>:<secret>). - Read
X-Axonflow-Clientto derive the request's scope. Examples:openclaw/2.1.0→pluginscopecursor-plugin/1.1.0→pluginscopesdk-typescript/7.8.0→sdkscope- (header absent) →
fullscope (default)
- Read
X-License-Tokenif present:- Validate the Ed25519 signature against the SaaS Plugin signing key.
- Check the token's
audis in the SaaS Plugin path's accept list (axonflow.saas.plugin,axonflow.saas.full). Reject with 401cross_quadrant_tokenotherwise. - Check the token's
HasScope(<derived-scope>)returns true. A SaaS Plugin token withaud=axonflow.saas.pluginmatchespluginscope but notsdkscope; aaxonflow.saas.fulltoken matches any scope. Reject with 401scope_mismatchotherwise. - Look up
plugin_user_licensesrow by JTI; requirerevoked_at IS NULLand not-yet-expired. - Resolve the request's effective tier to the row's tier (
ProorPremium).
- If no
X-License-Token: effective tier =Freebaseline. - Downstream handlers (rate limiter, audit cleanup, future capability gates) read the resolved tier and apply per-tier limits.
The X-Axonflow-Client derivation is honest scope enforcement, not a security boundary. A determined caller can spoof the header. The aud-based rejection at the validator is the actual cheating-resistance; the header is for the honest 99%. Same posture as DRM in any one-time-payment product.
The six quadrants today
Self-Hosted × Full (axonflow.self_hosted.full)
The classic AxonFlow Enterprise quadrant. Customer operates the agent in their own VPC; tier comes from AXONFLOW_LICENSE_KEY env var read at startup. Tiers in this quadrant: Community / Evaluation / Professional / Enterprise / EnterprisePlus, per the self-hosted licensing doc.
Tokens issued by the keygen CLI. Validity typically 90 days for the Evaluation tier, 1–2 years for paid tiers, by default with keygen -aud axonflow.self_hosted.full (the unchanged default).
SaaS × Plugin (axonflow.saas.plugin) — V1 Pro tier
The first SaaS quadrant to ship paying customers. Buyers purchase via Stripe Checkout for $9.99 (one-time, 90 days of Pro tier). The Stripe webhook handler mints the token at aud=axonflow.saas.plugin, tier=Pro, tenant_id=<buyer's cs_<uuid>>, exp=issued_at + 90 days.
Tier limits:
| Resource | Free | Pro | Premium (planned) |
|---|---|---|---|
| Audit retention | 3 days | 30 days | 90 days |
| Daily event quota | 200 | 1000 | 5000 |
Tokens are issued by the in-agent Stripe webhook handler with the plugin-claim Ed25519 signing key (separate from the Evaluation / Enterprise keys for blast-radius isolation). Recommended production posture: only the issuer service holds the signing key; agents hold the verifier-only public key (AXONFLOW_PLUGIN_CLAIMED_PUBLIC_KEY).
Self-Hosted × Plugin (axonflow.self_hosted.plugin)
The future Plugin In-VPC product — privacy-sensitive small companies running OpenClaw or another plugin-based agent runtime in their own VPC. Skeleton already in the keygen (keygen -aud axonflow.self_hosted.plugin); product launch + tier ladder + pricing are demand-driven, not date-driven.
Reserved future quadrants
- SaaS × SDK (
axonflow.saas.sdk) — when SDK-only pricing is greenlit. - SaaS × Full (
axonflow.saas.full) — when B2B SaaS is greenlit. - Self-Hosted × SDK (
axonflow.self_hosted.sdk) — when SDK-only Self-Hosted pricing is greenlit.
All three validators ship from day one with their accept lists in place — adding the product is a config change (which aud to set at issuance, what Stripe Product to wire), not an architecture change.
How HasScope actually works
The HasScope(scope) helper lives on ServiceLicensePayload. It checks set membership: a token with aud=axonflow.*.full matches any scope query (because full means "no scope restriction"). That gives forward compatibility — adding axonflow.*.http later is a constants-table append, not a validator-code change.
token aud | HasScope("plugin") | HasScope("sdk") | HasScope("full")
axonflow.saas.plugin | ✅ true | ❌ false | ❌ false
axonflow.saas.sdk | ❌ false | ✅ true | ❌ false
axonflow.saas.full | ✅ true | ✅ true | ✅ true
axonflow.self_hosted.full | ✅ true | ✅ true | ✅ true
(no aud — legacy fallback) | ✅ true | ✅ true | ✅ true (treated as self_hosted_full)
Cross-quadrant misuse — for example a SaaS Plugin Pro token sent to the self-hosted license loader — fails earlier, at the accept-list check, before HasScope is consulted.
Backward compat for legacy tokens
Existing self-hosted Enterprise / Evaluation tokens issued before ADR-050 carry empty aud. The validator falls back to treating them as axonflow.self_hosted.full — the only quadrant they could plausibly belong to. No production breakage at upgrade time.
The fallback is removed at the next major key rotation when all in-flight tokens carry explicit aud.
What this means for integrators
Most integrators don't need to think about the matrix at all. The plugins and SDKs set X-Axonflow-Client automatically; the agent reads X-License-Token when present; the validators do their job. The matrix is exposed when something goes wrong — a 401 with cross_quadrant_token or scope_mismatch reason tells you which axis you crossed.
If you're issuing your own tokens (e.g. via the keygen CLI for a self-hosted Enterprise rollout) the only thing to know is:
- Default
audisaxonflow.self_hosted.full— that matches what you want unless you're explicitly issuing for the future Plugin In-VPC or SDK product. - The
keygen -aud <value>flag accepts onlyaxonflow.self_hosted.{plugin,sdk,full}— cross-quadrant SaaS values are rejected at issuance. - Tokens issued for SaaS quadrants come from the in-agent Stripe webhook with a different signing key entirely; you don't issue them yourself.
References
- Auth and header matrix — wire-level reference for
X-Axonflow-Client+X-License-Token - Self-hosted licensing — Self-Hosted × Full quadrant tier ladder
- SDK Authentication — what the SDK sets automatically vs what you configure
