Entitlements
August 29, 2026 · View on GitHub
ClawMetry is open-core. This OSS package is the free layer; a
closed-source companion package (clawmetry-pro) delivers the paid layer
and is fetched only when a valid license key or a cloud entitlement is
present. This document explains what the split is, how the resolver
decides, what "GRACE" means today, and what the /api/entitlement surface
and clawmetry license CLI actually return.
Everything below is grounded in code you can read: the resolver and its
constants live in clawmetry/entitlements.py,
the offline key verifier in clawmetry/license.py,
and the HTTP surface in routes/entitlement.py.
TL;DR. Nothing is enforced yet. The resolver runs in grace mode until a future release flips the default, so every check currently answers "allowed" no matter which tier resolved. Wiring the gate in now is a no-op by design — it lets the UI render the right locks/CTAs and lets the daemon report an accurate plan without changing behaviour for any current user.
The open-core split
FREE — always available in this OSS package
The FREE layer needs no key, no network call, and no account.
Runtimes (from entitlements.FREE_RUNTIMES):
openclaw— OpenClaw agentsnemoclaw— NVIDIA NemoClaw agentsgoose— Goose agents (Block, Apache-2.0)
Free here means genuinely free: each of these adapters ships in this
package (clawmetry/adapters/), so pip install clawmetry observes them
with no account, no licence key, and no wheel download. A runtime listed as
free whose reader lived only in the closed wheel would not be free in any
sense a user cares about, so tests/test_phase4_adapter_move.py pins the
invariant.
The rule for which runtimes are free
An open-source runtime gets a free, open-source adapter. A commercial vendor product stays paid. Whoever pays a vendor for the agent will pay to observe it; whoever runs an OSS agent is part of a community we would rather reach than bill. Goose moved free on 2026-08-19 as the first application of the rule, for three reasons worth recording:
- Its maintainers will accept a ClawMetry tutorial into their own docs
only if it works without a paid plan (
aaif-goose/goose#11282). That placement is worth more than the runtime ever earned us. - An audit of every paying account found zero that depend on Goose — or on any other OSS runtime. Every payer is anchored on OpenClaw (already free) or on a runtime we keep paid.
- Free alternatives in this category already publish integration pages for these runtimes. Being the paid option loses the comparison before anyone installs us.
nemo_governance (policy enforcement layered on top of any runtime) is a
separate free feature, not a runtime — see the feature list below.
Chat-channel adapters (all 23 are free; entitlements.ALL_CHANNELS):
Telegram, Signal, WhatsApp, Discord, Slack, IRC, iMessage, WebChat, Google Chat, Microsoft Teams, BlueBubbles, Matrix, Mattermost, LINE, Nostr, Twitch, Feishu, Zalo, Tlon, Synology Chat, Nextcloud Talk, ClickClack, Buzz.
There is no paid-channel tier — every adapter unlocks on every plan. What tiers govern is how many channels can be active concurrently (see the capacity table below).
Features (entitlements.FREE_FEATURES):
sessions, transcripts, usage, brain, flow, tracing, health,
logs, crons, channels, nemo_governance, overview.
These map to the tabs and API surface listed in README.md and
ARCHITECTURE.md. If you can see it in pip install clawmetry on its
own, it's free.
PAID — shipped in the clawmetry-pro package
The paid layer is not in this repository. It downloads separately when either a self-hosted license key or a cloud plan resolves. The lists below live here so the free UI can render locked rows with an accurate "what would this unlock" preview.
Additional runtimes (entitlements.PAID_RUNTIMES):
claude_code, codex, cursor, aider, opencode,
qwen_code, hermes, picoclaw, nanoclaw, pi, deepagents,
n8n, antigravity, copilot, grok, grok_bot, qm, deepseek_harness, exo,
kimi, devin, gemini_cli, cline, openhands, openworker, lovable.
Additional features, split across three tiers:
| Bucket | Constant | Features |
|---|---|---|
| Starter | STARTER_FEATURES | multi_runtime, fleet, cloud_sync, all_channels, approval_queue, budget_limits, per_runtime_health_timeline |
| Pro-only | PRO_ONLY_FEATURES | per_run_waste_flags, per_run_compare, error_triage, self_evolve, asset_registry, eval_suite, tool_policy, otel_export, custom_webhooks, custom_runtime_ingest, custom_alerts, alert_webhooks, anomaly_detection, cost_optimizer, compliance_pack, audit_logs, rbac |
| Enterprise | ENTERPRISE_FEATURES | siem_export, sso, air_gapped_license, custom_data_residency, org_analytics |
audit_logs and rbac moved from Enterprise to Pro on 2026-08-25. Pro was
sold as "the governance layer" while every governance primitive sat in
Enterprise. audit_logs moved with a working surface behind it (append-only
hash chain, real producers, /api/audit-log, the Security-tab feed). rbac
has no implementation yet and is deliberately not advertised on the
pricing page: there is no principal to attach a role to until agent identity
ships. Ship the capability, then the claim.
Display labels for every feature live in entitlements.FEATURE_LABELS.
Tiers
Tier identifiers (entitlements.TIER_*):
| Identifier | Label | Source | How you get it |
|---|---|---|---|
oss | OSS | this package | default when nothing else resolves |
cloud_free | Free | cloud plan cache | signed-up cloud account, no paid plan |
trial | Trial | cloud plan cache | time-limited full-feature evaluation |
cloud_starter | Starter | cloud plan cache | paid cloud plan |
cloud_pro | Pro | cloud plan cache | paid cloud plan |
pro | Self-hosted Pro | local license key | Ed25519-signed key on disk |
enterprise | Enterprise | license key or cloud | contract-level plan |
Per-tier feature grants
| Tier | Features granted |
|---|---|
oss | FREE_FEATURES only |
cloud_free | FREE_FEATURES only |
cloud_starter | FREE_FEATURES ∪ STARTER_FEATURES |
trial | FREE_FEATURES ∪ PAID_FEATURES |
cloud_pro | FREE_FEATURES ∪ PAID_FEATURES |
pro | FREE_FEATURES ∪ PAID_FEATURES |
enterprise | everything, including ENTERPRISE_FEATURES |
Per-tier capacity caps
| Tier | Retention (days) | Concurrent channels | Nodes |
|---|---|---|---|
oss | 7 | 3 | 1 |
cloud_free | 7 | 3 | 1 |
trial | 30 | unlimited | unlimited (license-bound) |
cloud_starter | 30 | unlimited | unlimited (license-bound) |
cloud_pro | 90 | unlimited | unlimited (license-bound) |
pro | 90 | unlimited | unlimited (license-bound) |
enterprise | unlimited | unlimited | unlimited (license-bound) |
"unlimited (license-bound)" means the actual node cap for that install
comes off the license payload or cached cloud plan, not the static tier
ceiling. Constants: _TIER_RETENTION_DAYS, _TIER_CHANNEL_LIMIT,
_TIER_NODE_LIMIT.
CLAWMETRY_RETENTION_DAYS overrides the tier default at runtime for
retention only.
Resolution order
entitlements.get_entitlement() returns an Entitlement dataclass with
tier, source, node_limit, expiry, features, runtimes, and
grace. Sources are tried in this order — first hit wins, every miss
falls through silently:
- Local signed license file —
~/.clawmetry/license.key. Verified offline with Ed25519 byclawmetry.license; the public key ships embedded in this OSS package. Missing → skip. Malformed → warn and skip. Expired → warn and skip. Never raises. - Cached cloud plan —
~/.clawmetry/cloud_plan.json, written by the sync daemon after each heartbeat that returned a plan payload. Same defensive rules. - OSS free — the built-in fallback (
TIER_OSS,FREE_FEATURES,FREE_RUNTIMES).
The result is cached for _CACHE_TTL_SECS (60s). POST /api/entitlement/refresh invalidates the cache and re-resolves.
GRACE vs ENFORCE
Everything in the entitlement engine is currently wired but inert.
Entitlement.grace defaults to True, and entitlements.is_enforced()
reads the CLAWMETRY_ENFORCE environment variable — 1, true, yes,
on all count as on. The default is off. While off, every allows_*
call returns True regardless of the resolved tier, so:
- Users on the OSS tier see every feature the same way they did before.
- The dashboard can still render lock icons and "would upgrade to Pro" hints, driven by the same resolver, without any endpoint actually returning 402/403.
- The daemon can still report an accurate resolved tier via
/api/entitlement.
CLAWMETRY_ENFORCE_AT optionally carries the announced enforce-at
moment (ISO date, ISO datetime, or epoch seconds) so the UI can show
"enforced in N days" copy ahead of the flip. It has no effect on
behaviour — the flip itself happens when a future release changes the
default of CLAWMETRY_ENFORCE.
Nothing in this document changes user behaviour today. It documents what the engine will enforce once the operator flips the switch.
The /api/entitlement surface
routes/entitlement.py exposes the resolved entitlement and a large
family of preview/diff/batch helpers. The stable everyday endpoints are:
| Endpoint | Purpose |
|---|---|
GET /api/entitlement | Resolved entitlement for this install — the Entitlement.to_dict() payload (tier, tier_label, tier_rank, source, node_limit, expiry, grace, enforced, enforce_at, runtimes, features, locked_runtimes, locked_features, next-tier diff, …). |
POST /api/entitlement/refresh | Invalidate the 60s cache and re-resolve. Returns the same shape. |
GET /api/entitlement/upgrade-diff | What the next tier would add. |
GET /api/entitlement/downgrade-diff | What the previous tier would remove. |
GET /api/entitlement/tier-diff?to=<tier> | Diff between the current tier and any target. |
GET /api/entitlement/lock-reason?feature=<f> | Why a specific feature is locked and the minimum tier that would unlock it. |
GET /api/entitlement/required-tier?feature=<f> | Cheapest tier that includes the given feature/runtime/channel-count/etc. |
Beyond these there is a large family of preview / batch / capacity /
'at-tier' / rollup (has-all, missing-all, missing-all-at) /
row-detail complement (missing-features, missing-runtimes) /
multi-bundle boolean-fold (has-features-bundle-batch,
has-runtimes-bundle-batch) endpoints that let a UI answer questions
like "what does tier X look like at N channels", "which tiers are
affordable at this node count", "what does the path from tier A to tier B
unlock at each step", "does the resolved install grant this whole bundle
in one boolean fold" and "what's blocking the upgrade off ONE per-axis
denial payload". They all read the same in-memory tier matrix and never
mutate state. See routes/entitlement.py for the full list.
Boolean-fold bundle-batch endpoints
POST /api/entitlement/has-features-bundle-batch and POST /api/entitlement/has-runtimes-bundle-batch fold N caller-supplied
feature/runtime bundles to N has_* booleans in one round-trip — the
boolean-fold sibling of /min-tier-for-features-batch /
/min-tier-for-runtimes-batch on the same bundle axis.
Request body (byte-identical to the min-tier-for-*-batch siblings):
{"bundles": [["fleet", "sso"], ["otel_export"], []]}
A bare list-of-strings is treated as one bundle (single-bundle shorthand).
400 on missing / non-list / empty bundles key.
Response envelope (6 keys):
{
"bundles": [...],
"count": 3,
"current_tier": "oss",
"current_tier_rank": 0,
"grace": true,
"enforced": false
}
Per-bundle row (5 keys; has_features / has_runtimes mirrors the
singular scalar's return-slot name):
{
"features": ["fleet", "sso"],
"unknown": [],
"kind": "features",
"count": 2,
"has_features": true
}
The features/runtimes, unknown, kind, and count keys are
byte-identical to the corresponding /min-tier-for-*-batch rows so a UI
can render "granted right now?" and "cheapest tier that grants it?"
side-by-side per bundle from two calls.
Behaviour notes:
- An unknown token anywhere in a bundle collapses that row's
has_*tofalse(matches the singularhas_features/has_runtimestypo-at-callsite posture — a typo surfaces viaunknown[]instead of silently appearing granted). - Empty, all-unknown,
None, and non-iterable bundles surface as a stable row withhas_*: false. - Runtime aliases are canonicalised per-bundle before the membership check
(
claude-code→claude_code); unknown ids echo raw intounknown[]. - Never raises: per-bundle failures short-circuit to the empty row shape so the batch keeps building.
- Ships in GRACE mode —
has_*reads the live grant via the same resolver as the singular scalar. A paid feature returnstruein grace; FREE runtimes returntrueon the live install regardless of rollout state.
Every endpoint is defensive: a resolver failure falls back to the OSS- free snapshot (identical shape) rather than 500-ing, so a UI can rely on the response shape being stable.
Row-detail batch endpoint: missing-all-at-batch
GET /api/entitlement/missing-all-at-batch?tiers=oss,cloud_pro,...&features=a,b&runtimes=x,y&channels=N&retention_days=N&nodes=N
Batch what-if row-detail complement of has-all-at-batch. Fixes ONE
5-axis mixed bundle and sweeps across N caller-supplied perspective_tiers,
returning per-axis denial detail for each tier in one round-trip. Answers
"out of {fleet, sso, claude_code, 100 channels, 90d retention, 100 nodes},
which axes are still blocked at OSS vs Cloud Starter vs Cloud Pro vs
Enterprise?" for a paywall diagnostics matrix without N separate calls to
missing_all_at().
Grace-independent by construction: reads static per-tier grant tables
via _hypothetical_entitlement on the feature/runtime axes and
_TIER_CHANNEL_LIMIT / _TIER_RETENTION_DAYS / _TIER_NODE_LIMIT on the
capacity axes — so the answer is byte-identical under grace vs enforce for
the same inputs. This differs from the LIVE missing-all endpoint (which
reads the resolver's grace pass-through).
Per-tier row: mirrors has_all_at_batch on the axis-echo slots with a
per-axis missing sub-dict instead of a single has_all_at bool:
{
"tier": "oss",
"tier_label": "OSS",
"tier_rank": 0,
"missing": {
"features": ["fleet"],
"runtimes": ["claude_code"],
"channels": 100,
"retention_days": 90,
"nodes": 100
}
}
The scalar is clawmetry.entitlements.missing_all_at_batch(perspective_tiers, *, features, runtimes, channels, retention_days, nodes).
Path-walk endpoint: has-all-at-path
GET /api/entitlement/has-all-at-path?from=<id>&to=<id>&features=a,b&runtimes=x,y&channels=N&retention_days=N&nodes=N
Aggregate mixed-axis path-shaped boolean-fold. Fixes ONE 5-axis bundle
and sweeps across every purchasable rung between from and to, returning
one row per rung with the aggregate has_all_at fold at that rung. Answers
"at which tier does this whole 5-axis bundle unlock?" in one round-trip.
Per-rung has_all_at byte-equals has_all_at() for the same (rung, bundle)
pair. Grace-independent by construction: reads static per-tier grant
tables via _hypothetical_entitlement on the feature/runtime axes and
_TIER_CHANNEL_LIMIT / _TIER_RETENTION_DAYS / _TIER_NODE_LIMIT on the
capacity axes — so the answer is byte-identical under grace vs enforce for
the same inputs.
Per-rung row: { tier, tier_label, tier_rank, has_all_at }.
Envelope keys: from, from_label, from_rank, to, to_label,
to_rank, direction (upgrade | downgrade | lateral | identity |
unknown), features, runtimes, channels, retention_days, nodes,
unknown_features, unknown_runtimes, supplied_axes, supplied_count,
path, path_length, allowed_count, all_allowed, any_allowed,
required_tier, required_tier_label, required_tier_rank, plus the
standard resolver envelope (current_tier, current_tier_rank, grace,
enforced).
Unknown or missing endpoints return 200 with path=[] and
direction="unknown" (never 4xxs or 5xxs). Ships in GRACE mode.
The scalar is clawmetry.entitlements.has_all_at_path(from_tier, to_tier, *, features, runtimes, channels, retention_days, nodes).
Row-detail path-walk endpoint: missing-all-at-path
GET /api/entitlement/missing-all-at-path?from=<id>&to=<id>&features=a,b&runtimes=x,y&channels=N&retention_days=N&nodes=N
Aggregate mixed-axis path-shaped row-detail complement of has-all-at-path.
Fixes ONE 5-axis bundle and sweeps across every purchasable rung between
from and to, returning per-axis denial detail at each rung in one
round-trip. Answers "at which rung does each per-axis slot in this 5-axis
bundle clear?" for an upgrade-walkthrough tooltip without first calling
/tier-path for the rung list and then N calls to missing_all_at().
Grace-independent by construction: reads static per-tier grant tables
via _hypothetical_entitlement on the feature/runtime axes and
_TIER_CHANNEL_LIMIT / _TIER_RETENTION_DAYS / _TIER_NODE_LIMIT on the
capacity axes — so the answer is byte-identical under grace vs enforce for
the same inputs.
Per-rung row:
{
"tier": "cloud_starter",
"tier_label": "Starter",
"tier_rank": 2,
"missing": {
"features": ["sso"],
"runtimes": [],
"channels": null,
"retention_days": null,
"nodes": null
}
}
Complement invariant with has-all-at-path: any(row["missing"].values())
byte-equals not row["has_all_at"] on the paired boolean-fold row for
every fully-parseable bundle.
Unknown or missing endpoints return 200 with path=[] (never 4xxs or 5xxs).
Ships in GRACE mode.
The scalar is clawmetry.entitlements.missing_all_at_path(from_tier, to_tier, *, features, runtimes, channels, retention_days, nodes).
Bundle path-walk boolean-fold endpoint: has-all-bundle-at-path
POST /api/entitlement/has-all-bundle-at-path?from=<id>&to=<id>
Path-shaped bundle sibling of has-all-bundle-at (singular perspective)
and bundle-shaped counterpart of has-all-at-path (kwargs-shaped path
walker). Fixes ONE 5-axis bundle and sweeps across every purchasable
rung between from and to, returning per-rung aggregate boolean-fold
in one round-trip. Answers "at which rung does this WHOLE 5-axis bundle
unlock?" straight from the bundle dict without first normalising it by
hand and calling /has-all-at-path, or first calling /tier-path and
then N calls to /has-all-bundle-at.
Grace-independent by construction: reads static per-tier grant
tables via _hypothetical_entitlement on the feature/runtime axes and
_TIER_CHANNEL_LIMIT / _TIER_RETENTION_DAYS / _TIER_NODE_LIMIT on
the capacity axes — so the answer is byte-identical under grace vs
enforce for the same inputs.
Request body (byte-identical to has-all-bundle-at — wrapped or
bare-dict shorthand):
{"bundle": {"features": ["fleet"], "runtimes": ["claude_code"],
"channels": 5, "retention_days": 30, "nodes": 2}}
Per-rung row:
{
"tier": "cloud_pro",
"tier_label": "Cloud Pro",
"tier_rank": 3,
"features": ["fleet"],
"runtimes": ["claude_code"],
"channels": 5,
"retention_days": 30,
"nodes": 2,
"has_all_at": true
}
400 on missing / non-object bundle. Unknown or missing endpoints
return 200 with path=[] (never 4xxs or 5xxs). Ships in GRACE mode.
The scalar is clawmetry.entitlements.has_all_bundle_at_path(from_tier, to_tier, bundle).
Bundle path-walk row-detail endpoint: missing-all-bundle-at-path
POST /api/entitlement/missing-all-bundle-at-path?from=<id>&to=<id>
Row-detail path-shaped bundle sibling of the boolean-fold
has-all-bundle-at-path and bundle-shaped counterpart of
missing-all-at-path. Fixes ONE 5-axis bundle and sweeps across every
purchasable rung between from and to, returning per-axis denial
detail at each rung in one round-trip. Answers "at which rung does
each per-axis slot in this 5-axis bundle clear?" straight from the
bundle dict without first normalising it by hand and calling
/missing-all-at-path, or first calling /tier-path and then N calls
to the singular row-detail per-perspective seat.
Grace-independent by construction — same static-table read pattern as the paired boolean-fold endpoint.
Request body: byte-identical to has-all-bundle-at-path above.
Per-rung row:
{
"tier": "cloud_starter",
"tier_label": "Starter",
"tier_rank": 2,
"features": ["fleet"],
"runtimes": ["claude_code"],
"channels": 5,
"retention_days": 30,
"nodes": 2,
"missing": {
"features": ["fleet"],
"runtimes": [],
"channels": null,
"retention_days": null,
"nodes": null
}
}
Complement invariant with has-all-bundle-at-path: per rung,
any(row["missing"].values()) byte-equals not row["has_all_at"] on
the paired boolean-fold row for every fully-parseable bundle.
400 on missing / non-object bundle. Unknown or missing endpoints
return 200 with path=[] (never 4xxs or 5xxs). Ships in GRACE mode.
The scalar is clawmetry.entitlements.missing_all_bundle_at_path(from_tier, to_tier, bundle).
Bundle batch path-walk boolean-fold endpoint: has-all-bundle-batch-at-path
POST /api/entitlement/has-all-bundle-batch-at-path?from=<id>&to=<id>
Bundle-axis batch sibling of has-all-bundle-at-path (singular
bundle) and path-shaped counterpart of has-all-bundle-batch-at
(perspective-batch, no path). Fixes ONE (from, to) endpoint pair
and fans out over N caller-supplied aggregate 5-axis bundles,
returning one per-bundle cell with its own rung-by-rung path walk
in ONE round-trip. Lets a paywall matrix comparing several
hypothetical whole configs ("Starter-shaped install vs Pro-shaped
install vs Enterprise-shaped install") render the per-config "at
which rung does this WHOLE bundle unlock?" walk for EVERY column
off ONE call instead of N calls to has-all-bundle-at-path.
Grace-independent by construction -- same static-table walk as the singular endpoint applied per bundle cell.
Request body:
{
"bundles": [
{"features": ["fleet"], "runtimes": ["claude_code"]},
{"channels": 5, "retention_days": 30, "nodes": 2},
{}
]
}
Also accepts the {"bundles": {...}} bare-dict shorthand for ONE
bundle (matches /has-all-bundle-batch posture). from and to are
required query args.
Per-bundle cell:
{
"bundle_index": 0,
"features": ["fleet"],
"runtimes": ["claude_code"],
"channels": 5,
"retention_days": 30,
"nodes": 2,
"path": [
{"tier": "cloud_starter", "tier_label": "Starter", "tier_rank": 1,
"features": ["fleet"], "runtimes": ["claude_code"],
"channels": 5, "retention_days": 30, "nodes": 2,
"has_all_at": true}
],
"path_length": 1,
"allowed_count": 1,
"all_allowed": true,
"any_allowed": true
}
Per-cell per-rung has_all_at byte-equals /has-all-bundle-at-path
for the same (from, to, bundle) triple. Direction (upgrade /
downgrade / lateral / identity / unknown) is envelope-level
and shared across every cell.
400 on missing / non-list-non-dict / empty bundles. Unknown or
missing from / to returns 200 with bundles=[] / count=0
(never 4xxs on endpoint validity or 5xxs). Ships in GRACE mode.
The scalar is clawmetry.entitlements.has_all_bundle_batch_at_path(from_tier, to_tier, bundles).
Bundle batch path-walk row-detail endpoint: missing-all-bundle-batch-at-path
POST /api/entitlement/missing-all-bundle-batch-at-path?from=<id>&to=<id>
Row-detail complement of has-all-bundle-batch-at-path and
row-detail bundle-axis batch sibling of missing-all-bundle-at-path.
Same per-bundle fan-out plus per-rung path walk, but each rung
carries the per-axis missing dict instead of the boolean fold.
Answers "for each of these hypothetical whole configs, which axis is
denied at each rung along the walk?" for a paywall-matrix upgrade
storyline.
Grace-independent by construction -- same static-table walk as the paired boolean-fold endpoint.
Request body: byte-identical to has-all-bundle-batch-at-path
above.
Per-bundle cell:
{
"bundle_index": 0,
"features": ["fleet"],
"runtimes": ["claude_code"],
"channels": 500,
"retention_days": null,
"nodes": null,
"path": [
{"tier": "cloud_starter", "tier_label": "Starter", "tier_rank": 1,
"features": ["fleet"], "runtimes": ["claude_code"],
"channels": 500, "retention_days": null, "nodes": null,
"missing": {"features": [], "runtimes": [], "channels": 500,
"retention_days": null, "nodes": null}}
],
"path_length": 1,
"denied_count": 1,
"all_denied": true,
"any_denied": true
}
Complement invariant with has-all-bundle-batch-at-path: per cell
per rung, any(row["missing"].values()) byte-equals
not paired_row["has_all_at"] on the paired boolean-fold call for
every fully-parseable bundle.
400 on missing / non-list-non-dict / empty bundles. Unknown or
missing endpoints return 200 with bundles=[] / count=0 (never
4xxs on endpoint validity or 5xxs). Ships in GRACE mode.
The scalar is clawmetry.entitlements.missing_all_bundle_batch_at_path(from_tier, to_tier, bundles).
Source-batch bundle path-walk boolean-fold endpoint: has-all-bundle-from-path-batch
POST /api/entitlement/has-all-bundle-from-path-batch?to=<id>
Source-axis batch bundle-shaped sibling of has-all-bundle-at-path
(singular source path) and bundle-shape twin of
has-features-from-path-batch / has-runtimes-from-path-batch on the
same source-batch seat. Fixes ONE 5-axis bundle and walks the rungs
between N candidate SOURCE tiers and one shared to in ONE
round-trip. Answers "for each of the tiers my fleet sits on today,
does this WHOLE 5-axis subscription state unlock at every rung
climbed toward <to>?" without N calls to has-all-bundle-at-path.
Grace-independent by construction — same static-table walk as
the singular has-all-bundle-at-path applied per source.
Request body (canonical, or bare-axis shorthand alongside
from_tiers):
{"from_tiers": ["oss", "cloud_starter"],
"bundle": {"features": ["fleet"], "runtimes": ["claude_code"],
"channels": 5, "retention_days": 30, "nodes": 2}}
Per-source row:
{
"from": "oss",
"from_label": "OSS",
"from_rank": 0,
"direction": "upgrade",
"path": [<has-all-bundle-at-path row>, ...],
"path_length": 4,
"allowed_count": 4,
"all_allowed": true,
"any_allowed": true
}
Each path row byte-equals has-all-bundle-at-path's .path for
the same (from, to, bundle) triple. Per-source path lengths can
differ (the rungs walked depend on the source). Unknown source ids
echo into unknown_tiers[] without short-circuiting the batch.
- 400 on missing / non-object
bundle. - Missing / blank / unknown
to, or empty / missingfrom_tiers→ 200 withtiers=[](never 4xxs). - Never 5xxs.
Ships in GRACE mode.
The scalar is clawmetry.entitlements.has_all_bundle_from_path_batch(from_tiers, to_tier, bundle).
Source-batch bundle path-walk row-detail endpoint: missing-all-bundle-from-path-batch
POST /api/entitlement/missing-all-bundle-from-path-batch?to=<id>
Row-detail complement of has-all-bundle-from-path-batch on the
source-axis batch seat. Same request body; per-source rollups renamed
for the missing seat (denied_count / all_denied / any_denied)
and per-rung path rows carry the per-axis missing dict via
missing-all-bundle-at-path.
Per-source row:
{
"from": "oss",
"from_label": "OSS",
"from_rank": 0,
"direction": "upgrade",
"path": [<missing-all-bundle-at-path row>, ...],
"path_length": 4,
"denied_count": 4,
"all_denied": true,
"any_denied": true
}
Complement invariant with has-all-bundle-from-path-batch: per
source per rung, any(row["missing"].values()) byte-equals
not row["has_all_at"] on the paired boolean-fold call for every
fully-parseable bundle.
- 400 on missing / non-object
bundle. - Missing / blank / unknown
to, or empty / missingfrom_tiers→ 200 withtiers=[](never 4xxs). - Never 5xxs.
Ships in GRACE mode.
The scalar is clawmetry.entitlements.missing_all_bundle_from_path_batch(from_tiers, to_tier, bundle).
Batch path-walk endpoint: has-all-at-path-batch
GET /api/entitlement/has-all-at-path-batch?from=<id>&to=a,b,c&features=x,y&runtimes=p,q&channels=N&retention_days=N&nodes=N
Aggregate mixed-axis batch companion of has-all-at-path. Fixes ONE
5-axis bundle and sweeps across every purchasable rung between from
and each of the N candidate to tiers in ONE round-trip, returning
per-destination path lists of aggregate has_all_at fold rows. Answers
"from my current rung, here are 3 tiers I'm considering: for the WHOLE
5-axis bundle show me at which rung this bundle unlocks along every
candidate path" for an upgrade-comparison matrix without N calls to
has-all-at-path or 5·N calls to the per-axis path-batch endpoints
plus a client-side AND-chain per rung per destination.
Per-destination path row byte-equals has-all-at-path's .path for
the same (from, to, bundle) triple. Per-destination path lengths can
legitimately differ (the rungs walked depend on the destination).
Grace-independent by construction — same static-table walk as the
singular endpoint applied per destination.
Per-destination row:
{
"to": "pro",
"to_label": "Self-hosted Pro",
"to_rank": 2,
"direction": "upgrade",
"path": [{"tier": "cloud_starter", "tier_label": "Starter", "tier_rank": 1, "has_all_at": true}],
"path_length": 1,
"allowed_count": 1,
"all_allowed": true,
"any_allowed": true
}
Envelope keys: from, from_label, from_rank, features,
runtimes, channels, retention_days, nodes, unknown_features,
unknown_runtimes, unknown_tiers, supplied_axes, supplied_count,
tiers, required_tier, required_tier_label, required_tier_rank,
plus the standard resolver envelope (current_tier,
current_tier_rank, grace, enforced).
Runtime-alias canonicalisation (claude-code → claude_code) is
applied per token upstream. Unknown feature/runtime tokens OR non-int
capacity collapse EVERY rung of EVERY destination to has_all_at=False
at the endpoint layer (matches the singular has-all-at-path
typo-False posture). Missing/blank/unknown from or an empty /
all-unknown destination CSV returns 200 with tiers=[] (never 4xxs or
5xxs). trial IS accepted as a destination via the lateral / identity
branches. Ships in GRACE mode.
The scalar is clawmetry.entitlements.has_all_at_path_batch(from_tier, to_tiers, *, features, runtimes, channels, retention_days, nodes).
Batch row-detail path-walk endpoint: missing-all-at-path-batch
GET /api/entitlement/missing-all-at-path-batch?from=<id>&to=a,b,c&features=x,y&runtimes=p,q&channels=N&retention_days=N&nodes=N
Aggregate mixed-axis batch companion of missing-all-at-path and
row-detail complement of has-all-at-path-batch at the batch-path
layer. Fixes ONE 5-axis bundle and sweeps across every purchasable rung
between from and each of the N candidate to tiers in ONE round-
trip, returning per-destination path lists of aggregate per-axis
missing row-detail rows. Answers "from my current rung, here are 3
tiers I'm considering: for the WHOLE 5-axis bundle show me which
per-axis slots are still locked at every rung climbed to reach each"
for an upgrade-comparison matrix without N calls to missing-all-at-path.
Grace-independent by construction — same static-table walk as the singular endpoint applied per destination.
Per-destination row:
{
"to": "pro",
"to_label": "Self-hosted Pro",
"to_rank": 2,
"direction": "upgrade",
"path": [
{"tier": "cloud_starter", "tier_label": "Starter", "tier_rank": 1,
"missing": {"features": ["sso"], "runtimes": [], "channels": null, "retention_days": null, "nodes": null}}
],
"path_length": 1,
"denied_count": 1,
"all_denied": true,
"any_denied": true
}
Envelope keys: identical to has-all-at-path-batch on the
axis-echo / resolver slots; per-destination rollup slots use
denied_count / all_denied / any_denied instead of the
boolean-fold allowed_count / all_allowed / any_allowed.
Complement invariant with has-all-at-path-batch: per destination per
rung, any(row["missing"].values()) byte-equals not row["has_all_at"]
on the paired boolean-fold row for every fully-parseable bundle.
Non-int capacity is the deliberate divergence: the row-detail slot
surfaces the raw string on every rung while the boolean-fold slot
collapses to False.
Missing/blank/unknown from or an empty / all-unknown destination CSV
returns 200 with tiers=[] (never 4xxs or 5xxs). Ships in GRACE mode.
The scalar is clawmetry.entitlements.missing_all_at_path_batch(from_tier, to_tiers, *, features, runtimes, channels, retention_days, nodes).
Source-batch path-walk endpoint: has-all-from-path-batch
GET /api/entitlement/has-all-from-path-batch?from=a,b,c&to=<id>&features=x,y&runtimes=p,q&channels=N&retention_days=N&nodes=N
Mirror-direction source-batch sibling of has-all-at-path-batch
(destination-batch): where the destination-batch fixes ONE source and
fans out over N candidate destinations, this fixes ONE destination and
fans out over N candidate sources in ONE round-trip. Answers "for each
of the tiers my fleet currently sits on, walking up to Enterprise for
the WHOLE 5-axis bundle, at which rung does this bundle unlock along
every candidate ladder?" for a source-side upgrade-comparison matrix
without N calls to has-all-at-path or 5·N calls to the per-axis
source-batch endpoints plus a client-side AND-chain per rung per source.
Per-source path row byte-equals has-all-at-path's .path for the
same (from, to, bundle) triple. Per-source path lengths can
legitimately differ (the rungs walked depend on the source).
Grace-independent by construction — same static-table walk as the
singular endpoint applied per source.
Per-source row:
{
"from": "oss",
"from_label": "OSS",
"from_rank": 0,
"direction": "upgrade",
"path": [{"tier": "cloud_starter", "tier_label": "Starter", "tier_rank": 1, "has_all_at": true}],
"path_length": 1,
"allowed_count": 1,
"all_allowed": true,
"any_allowed": true
}
Envelope keys: to, to_label, to_rank, features, runtimes,
channels, retention_days, nodes, unknown_features,
unknown_runtimes, unknown_tiers, supplied_axes, supplied_count,
tiers, required_tier, required_tier_label, required_tier_rank,
plus the standard resolver envelope (current_tier,
current_tier_rank, grace, enforced).
Runtime-alias canonicalisation (claude-code → claude_code) is
applied per token upstream. Unknown feature/runtime tokens OR non-int
capacity collapse EVERY rung of EVERY source to has_all_at=False at
the endpoint layer (matches the singular has-all-at-path typo-False
posture). Missing/blank/unknown to or an empty / all-unknown source
CSV returns 200 with tiers=[] (never 4xxs or 5xxs). trial IS
accepted as a source via the lateral / identity branches. Ships in
GRACE mode.
The scalar is clawmetry.entitlements.has_all_from_path_batch(from_tiers, to_tier, *, features, runtimes, channels, retention_days, nodes).
Source-batch row-detail path-walk endpoint: missing-all-from-path-batch
GET /api/entitlement/missing-all-from-path-batch?from=a,b,c&to=<id>&features=x,y&runtimes=p,q&channels=N&retention_days=N&nodes=N
Mirror-direction source-batch sibling of missing-all-at-path-batch
(destination-batch) and row-detail complement of
has-all-from-path-batch at the source-batch path layer. Fixes ONE
destination and fans out over N candidate sources in ONE round-trip,
returning per-source path lists of aggregate per-axis missing row-
detail rows. Answers "for each of the tiers my fleet currently sits on,
walking toward Enterprise for the WHOLE 5-axis bundle, which per-axis
slots are still locked at every rung climbed to reach it?" for a
source-side upgrade-comparison matrix without N calls to
missing-all-at-path.
Grace-independent by construction — same static-table walk as the singular endpoint applied per source.
Per-source row:
{
"from": "oss",
"from_label": "OSS",
"from_rank": 0,
"direction": "upgrade",
"path": [
{"tier": "cloud_starter", "tier_label": "Starter", "tier_rank": 1,
"missing": {"features": ["sso"], "runtimes": [], "channels": null, "retention_days": null, "nodes": null}}
],
"path_length": 1,
"denied_count": 1,
"all_denied": true,
"any_denied": true
}
Envelope keys: identical to has-all-from-path-batch on the
axis-echo / resolver slots; per-source rollup slots use denied_count
/ all_denied / any_denied instead of the boolean-fold
allowed_count / all_allowed / any_allowed.
Complement invariant with has-all-from-path-batch: per source per
rung, any(row["missing"].values()) byte-equals not row["has_all_at"]
on the paired boolean-fold row for every fully-parseable bundle.
Non-int capacity is the deliberate divergence: the row-detail slot
surfaces the raw string on every rung while the boolean-fold slot
collapses to False.
Missing/blank/unknown to or an empty / all-unknown source CSV returns
200 with tiers=[] (never 4xxs or 5xxs). Ships in GRACE mode.
The scalar is clawmetry.entitlements.missing_all_from_path_batch(from_tiers, to_tier, *, features, runtimes, channels, retention_days, nodes).
Bundle batch path-walk boolean-fold endpoint: has-all-bundle-at-path-batch
POST /api/entitlement/has-all-bundle-at-path-batch?from=<id>&to=a,b,c
Bundle-shaped destination-batch companion of has-all-bundle-at-path
(singular destination) and bundle-shaped counterpart of
has-all-at-path-batch (kwargs-shaped batch-path). Fixes ONE aggregate
5-axis bundle dict and sweeps across every purchasable rung between
from and each of the N candidate to tiers in ONE round-trip,
returning per-destination path lists of aggregate has_all_at fold
rows. Answers "from my current rung, here are 3 tiers I'm considering:
for this WHOLE 5-axis bundle dict show me at which rung this bundle
unlocks along every candidate path" straight from the bundle dict —
without first normalising it by hand and calling
/has-all-at-path-batch, or N calls to /has-all-bundle-at-path.
Grace-independent by construction — same static-table walk as the singular endpoint applied per destination.
Request body: byte-identical to has-all-bundle-at-path
({"bundle": {...}} wrapped form, or bare-dict shorthand).
Per-destination row:
{
"to": "pro",
"to_label": "Self-hosted Pro",
"to_rank": 2,
"direction": "upgrade",
"path": [
{"tier": "cloud_starter", "tier_label": "Starter", "tier_rank": 1,
"features": ["fleet"], "runtimes": ["claude_code"], "channels": 5,
"retention_days": null, "nodes": null, "has_all_at": true}
],
"path_length": 1,
"allowed_count": 1,
"all_allowed": true,
"any_allowed": true
}
Envelope keys: from, from_label, from_rank, features,
runtimes, channels, retention_days, nodes, unknown_tiers,
tiers, plus the standard resolver envelope (current_tier,
current_tier_rank, grace, enforced).
Per-destination path row byte-equals has-all-bundle-at-path's
.path for the same (from, to, bundle) triple. Per-destination path
lengths can legitimately differ (the rungs walked depend on the
destination). Runtime-alias canonicalisation (claude-code →
claude_code) is applied per token by the bundle normaliser. Unknown
feature id collapses every rung of every destination to
has_all_at=false (matches the singular typo posture). trial IS
accepted as a destination via the lateral / identity branches.
400 on missing / non-object bundle. Missing/blank/unknown from or
an empty / all-unknown destination CSV returns 200 with tiers=[]
(never 4xxs or 5xxs). Ships in GRACE mode.
The scalar is clawmetry.entitlements.has_all_bundle_at_path_batch(from_tier, to_tiers, bundle).
Bundle batch path-walk row-detail endpoint: missing-all-bundle-at-path-batch
POST /api/entitlement/missing-all-bundle-at-path-batch?from=<id>&to=a,b,c
Row-detail bundle-shaped destination-batch companion of
missing-all-bundle-at-path (singular destination), bundle-shaped
counterpart of missing-all-at-path-batch (kwargs-shaped batch-path),
and row-detail complement of has-all-bundle-at-path-batch at the
bundle-shaped batch-path layer. Fixes ONE aggregate 5-axis bundle dict
and sweeps across every purchasable rung between from and each of
the N candidate to tiers in ONE round-trip, returning per-destination
path lists of aggregate per-axis missing row-detail rows. Answers
"from my current rung, here are 3 tiers I'm considering: for this
WHOLE 5-axis bundle dict show me which per-axis slots are still locked
at every rung climbed to reach each" straight from the bundle dict.
Grace-independent by construction — same static-table walk as the singular endpoint applied per destination.
Request body: byte-identical to has-all-bundle-at-path-batch
above.
Per-destination row:
{
"to": "pro",
"to_label": "Self-hosted Pro",
"to_rank": 2,
"direction": "upgrade",
"path": [
{"tier": "cloud_starter", "tier_label": "Starter", "tier_rank": 1,
"features": ["fleet"], "runtimes": ["claude_code"], "channels": 5,
"retention_days": null, "nodes": null,
"missing": {"features": ["fleet"], "runtimes": [], "channels": null,
"retention_days": null, "nodes": null}}
],
"path_length": 1,
"denied_count": 1,
"all_denied": true,
"any_denied": true
}
Envelope keys: identical to has-all-bundle-at-path-batch on the
axis-echo / resolver slots; per-destination rollup slots use
denied_count / all_denied / any_denied instead of the boolean-fold
allowed_count / all_allowed / any_allowed.
Complement invariant with has-all-bundle-at-path-batch: per
destination per rung, any(row["missing"].values()) byte-equals not row["has_all_at"] on the paired boolean-fold row for every
fully-parseable bundle.
400 on missing / non-object bundle. Missing/blank/unknown from or
an empty / all-unknown destination CSV returns 200 with tiers=[]
(never 4xxs or 5xxs). Ships in GRACE mode.
The scalar is clawmetry.entitlements.missing_all_bundle_at_path_batch(from_tier, to_tiers, bundle).
Bundle-batch perspective row-detail endpoint: missing-all-bundle-batch-at
POST /api/entitlement/missing-all-bundle-batch-at?tier=<perspective>
Hypothetical-perspective row-detail sibling of has-all-bundle-batch-at
(boolean fold) and missing-all-bundle-batch (LIVE row detail). Folds N
caller-supplied 5-axis bundles to N per-axis missing dicts scoped by a
caller-supplied perspective_tier in one round-trip. Answers "which axes
of each bundle would tier <perspective> NOT grant?" for a pricing-matrix
walkthrough without N separate calls to missing_all_at().
Grace-independent by construction: reads static per-tier grant tables
via _hypothetical_entitlement on the feature/runtime axes and
_TIER_CHANNEL_LIMIT / _TIER_RETENTION_DAYS / _TIER_NODE_LIMIT on the
capacity axes — so grace vs enforce yields byte-identical row bodies.
At tier=oss, a paid-feature bundle reports missing.features=["fleet"]
even in grace, whereas the LIVE missing-all-bundle-batch reports
missing.features=[] for the same bundle via grace pass-through.
Request body (byte-identical to missing-all-bundle-batch and
has-all-bundle-batch-at):
{"bundles": [{"features": ["fleet"], "runtimes": ["claude_code"]}, {"channels": 5}]}
A bare dict is treated as one bundle (single-bundle shorthand).
Response envelope (9 keys):
{
"perspective_tier": "cloud_pro",
"perspective_tier_label": "Cloud Pro",
"perspective_tier_rank": 4,
"bundles": [...],
"count": 2,
"current_tier": "oss",
"current_tier_rank": 0,
"grace": true,
"enforced": false
}
Per-bundle row: mirrors has-all-bundle-batch-at byte-for-byte on the
axis-echo slots (features, runtimes, channels, retention_days,
nodes) with the fold slot swapped from has_all_at bool to a per-axis
missing dict:
{
"features": ["fleet"],
"runtimes": ["claude_code"],
"channels": null,
"retention_days": null,
"nodes": null,
"missing": {
"features": ["fleet"],
"runtimes": [],
"channels": null,
"retention_days": null,
"nodes": null
}
}
Complement invariant with has-all-bundle-batch-at: any(row["missing"].values())
strictly negates the paired has_all_at for every fully-parseable bundle
on a valid perspective.
- 400 on missing/blank
tier=, missing/empty/non-listbundles - 404 on unknown
tier=(body carrieswhich=tier) - Never 5xxs.
Ships in GRACE mode. The scalars are
clawmetry.entitlements.missing_all_bundle_batch_at(perspective_tier, bundles)
and the private _missing_all_bundle_row_at(perspective_tier, bundle).
The clawmetry license CLI
Two spellings, same effect: clawmetry activate <KEY> is a shortcut
for clawmetry license activate <KEY>. Every subcommand accepts
--json for scripting; the human table and the JSON envelope are
kept in step.
| Command | What it does |
|---|---|
clawmetry license | Show the current plan, license validity, expiry, node cap. |
clawmetry license activate <KEY> | Verify the key offline and install it at ~/.clawmetry/license.key. Restart the daemon to load clawmetry-pro. |
clawmetry license verify <KEY> | Dry-run — verify a key offline and print what it would unlock without writing anything to disk. Useful for support and pre-flight. |
clawmetry license deactivate | Remove the license file. Next restart reverts to OSS. |
clawmetry license fingerprint | Print the SHA-256 fingerprint of the embedded public key so you can compare it against the canonical value at https://clawmetry.com/security and confirm your install carries the genuine verification key. |
clawmetry activate <KEY> | Shortcut. Same as license activate; same --json envelope. |
How verification works. clawmetry.license uses the cryptography
package (already a hard dependency of this repo — no new deps) to
verify a compact header.payload.signature token with a bundled
Ed25519 public key. The private key lives on the license server;
signatures are checked entirely offline on your machine. A key that
fails signature verification, has a nbf in the future, or an exp
in the past is rejected — the CLI prints a status line, the JSON
envelope reports ok: false, and the exit code is non-zero.
What happens when a lock is hit
Once CLAWMETRY_ENFORCE=1 is set (or once the future release flips the
default), a locked endpoint returns a JSON body describing:
- what was requested (feature, runtime, or capacity axis and value),
- the tier that resolved,
- the minimum tier that would satisfy the request, and
- the upgrade path the UI should render.
Today, with grace on, none of that fires — every endpoint responds exactly as it did before entitlements existed. This document is here so that when the switch is flipped, no user has to reverse-engineer what changed.
Related code
| File | What lives there |
|---|---|
clawmetry/entitlements.py | Runtime/feature/tier constants, Entitlement dataclass, get_entitlement(), is_enforced(), capacity + tier-diff helpers. |
clawmetry/license.py | Ed25519 offline verification, activate / deactivate / inspect_key, current_license_info, pubkey_info. |
clawmetry/extensions.py | Plugin loader for clawmetry-pro. |
routes/entitlement.py | bp_entitlement — the full /api/entitlement HTTP surface. |
clawmetry/_gate.py, clawmetry/_paywall.py | Gate decorator and paywall-event bookkeeping used by feature routes. |