Skip to main content

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 scopePluginSDKFull
SaaS (we operate the agent — try.getaxonflow.com)axonflow.saas.pluginaxonflow.saas.sdkaxonflow.saas.full
Self-Hosted (you operate the agent — Docker / In-VPC)axonflow.self_hosted.pluginaxonflow.self_hosted.sdkaxonflow.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_KEY on 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-Token on 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:

  1. Resolve tenant_id from the Basic-auth credential (existing behaviour — cs_<uuid>:<secret>).
  2. Read X-Axonflow-Client to derive the request's scope. Examples:
    • openclaw/2.1.0plugin scope
    • cursor-plugin/1.1.0plugin scope
    • sdk-typescript/7.8.0sdk scope
    • (header absent) → full scope (default)
  3. Read X-License-Token if present:
    • Validate the Ed25519 signature against the SaaS Plugin signing key.
    • Check the token's aud is in the SaaS Plugin path's accept list (axonflow.saas.plugin, axonflow.saas.full). Reject with 401 cross_quadrant_token otherwise.
    • Check the token's HasScope(<derived-scope>) returns true. A SaaS Plugin token with aud=axonflow.saas.plugin matches plugin scope but not sdk scope; a axonflow.saas.full token matches any scope. Reject with 401 scope_mismatch otherwise.
    • Look up plugin_user_licenses row by JTI; require revoked_at IS NULL and not-yet-expired.
    • Resolve the request's effective tier to the row's tier (Pro or Premium).
  4. If no X-License-Token: effective tier = Free baseline.
  5. 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:

ResourceFreeProPremium (planned)
Audit retention3 days30 days90 days
Daily event quota20010005000

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 aud is axonflow.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 only axonflow.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