Skip to content

feat(entitlements): tier_unlocks(tier) — per-tier marginal unlocks ("what's new at X vs the tier below")#3228

Merged
vivekchand merged 1 commit into
mainfrom
feat/entitlements-tier-unlocks
Jun 21, 2026
Merged

feat(entitlements): tier_unlocks(tier) — per-tier marginal unlocks ("what's new at X vs the tier below")#3228
vivekchand merged 1 commit into
mainfrom
feat/entitlements-tier-unlocks

Conversation

@vivekchand

Copy link
Copy Markdown
Owner

What

Adds the marginal sibling of preview(tier): where preview() answers "what would the resulting Entitlement look like at tier X" (cumulative grant), tier_unlocks() answers "what does tier X first unlock vs the tier below it" (marginal grant) — the "what's new in Pro vs Starter" view a pricing-page row or upgrade-CTA card uses.

Python helper (clawmetry/entitlements.py)

  • tier_unlocks(target_tier) -> dict | None. Returns:
    {
      "tier": "cloud_pro",
      "tier_label": "Pro",
      "tier_rank": 2,
      "previous_tier": "cloud_starter",
      "previous_tier_label": "Starter",
      "previous_tier_rank": 1,
      "features": ["alert_webhooks", "anomaly_detection", "asset_registry", "cost_optimizer", "custom_alerts", "custom_runtime_ingest", "custom_webhooks", "error_triage", "eval_suite", "otel_export", "per_run_compare", "per_run_waste_flags", "self_evolve", "tool_policy"],
      "runtimes": []
    }
  • features / runtimes are the set difference between this tier's grant and the next-lower purchasable tier's grant. Trial is excluded from purchasables — a promotional grant never shows up as the upgrade source. Floor tiers (OSS / Cloud Free at rank 0) collapse to previous_tier = null and the marginal reports the full free grant.
  • Unknown / non-purchasable / empty / None → None. Never raises.

HTTP endpoint (routes/entitlement.py)

GET /api/entitlement/tier-unlocks?tier=<id> — sibling of /api/entitlement/preview. 400 on missing tier, 404 on unknown tier (including trial — not purchasable), 500 on resolver failure with a logged warning.

Why

preview(target) already renders the cumulative denormalised shape at a tier ("at Pro you'd have 90-day retention, unlimited channels, all paid features, …"). The CTA card uses that to show concrete numbers. But the pricing-page / "What's new at this tier" copy wants the marginal view — "Upgrade to Pro to unlock: per_run_waste_flags, otel_export, custom_alerts, …" — and that's been left for the client to derive by diffing _TIER_FEATURES itself. tier_unlocks closes that gap as a single canonical resolver call, same pattern as the other reverse-lookup helpers in the module.

Today's marginal sets, pinned by tests:

Tier (previous_tier) features (new at this tier) runtimes (new at this tier)
oss / cloud_free (null) FREE_FEATURES FREE_RUNTIMES
cloud_starter (← oss) STARTER_FEATURES every PAID_RUNTIMES
cloud_pro / pro (← cloud_starter) PRO_ONLY_FEATURES [] (already unlocked at Starter)
enterprise (← cloud_pro) ENTERPRISE_FEATURES []

Not changing

  • preview(tier) and /api/entitlement/preview are untouched (existing tests still green).
  • tier_catalog() is untouched — adding a marginal unlocks field per row is intentionally deferred to a follow-up PR; this PR is just the helper + endpoint.
  • Every other entitlement endpoint is untouched.
  • No __version__ bump, no [RELEASE] PR, no enforce-flip — stays in GRACE.

Tests

tests/test_entitlement_tier_unlocks.py (24 tests, all green):

  • shape pins (full keyset, sorted lists, tier metadata matches target)
  • per-tier marginals (oss / cloud_free floor collapses; starter → paid runtimes + STARTER_FEATURES; cloud_pro / pro → PRO_ONLY_FEATURES, no new runtimes; enterprise → ENTERPRISE_FEATURES)
  • safety (trial → None, unknown → None, empty/None → None, lowercases input, never raises, doesn't mutate live entitlement)
  • catalogue round-trip (union of marginals across purchasable tiers equals PAID_FEATURES | ENTERPRISE_FEATURES and PAID_RUNTIMES respectively; marginal feature sets are disjoint across tiers — no double-listing on the pricing page)
  • API surface (200 for pro/starter, 400 missing, 404 unknown, 404 trial, case-insensitive query)

Broader regression: 273 tests across test_entitlement_preview.py, test_entitlements_catalogue.py, test_entitlements.py, test_entitlement_api.py, test_entitlement_upgrade_diff.py, test_entitlement_capacity_diff.py, test_entitlement_next_tier.py, test_entitlement_prev_tier.py, test_entitlement_tier_diff.py, test_entitlement_tier_unlocks.py — all green.

ruff check clawmetry/entitlements.py routes/entitlement.py tests/test_entitlement_tier_unlocks.py clean.

Local operator verification checklist

Since the autonomous fire has no access to the local daemon, the live cloud snapshot, or a browser, the local operator needs to verify these steps before flipping to ready-for-review:

  • Copy clawmetry/entitlements.py, routes/entitlement.py, tests/test_entitlement_tier_unlocks.py into ~/.clawmetry/lib/pythonX.Y/site-packages/clawmetry/ (and matching paths for routes/ and tests/), clear __pycache__/.
  • launchctl kickstart -k gui/$(id -u)/com.clawmetry.sync to bounce the daemon.
  • curl -s 'http://localhost:8900/api/entitlement/tier-unlocks?tier=cloud_pro' | jq — expect previous_tier: "cloud_starter", features listing the 14 PRO_ONLY features, runtimes: [].
  • curl -s 'http://localhost:8900/api/entitlement/tier-unlocks?tier=cloud_starter' | jq — expect previous_tier: "oss", every paid runtime in runtimes, features listing the 7 STARTER_FEATURES.
  • curl -s 'http://localhost:8900/api/entitlement/tier-unlocks?tier=enterprise' | jq — expect previous_tier: "cloud_pro", features listing the 6 ENTERPRISE_FEATURES, runtimes: [].
  • curl -s 'http://localhost:8900/api/entitlement/tier-unlocks?tier=oss' | jq .previous_tier — expect null and features listing the free baseline.
  • curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8900/api/entitlement/tier-unlocks — expect 400.
  • curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:8900/api/entitlement/tier-unlocks?tier=trial' — expect 404 (trial is not purchasable).
  • curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:8900/api/entitlement/tier-unlocks?tier=nonsense_xyz' — expect 404.
  • Decrypt the live cloud snapshot and confirm the dashboard still loads (the new endpoint is additive; the existing entitlement payload is unchanged).
  • Browser-check: no regression on the upgrade-CTA card — it still reads off next_tier_diff from /api/entitlement; the new endpoint is opt-in.

🤖 Generated with Claude Code


Generated by Claude Code

…what's new at X vs the tier below)

Adds the marginal sibling of preview(tier): where preview() answers
"what would the resulting Entitlement *look like* at tier X" (cumulative
grant), tier_unlocks() answers "what does tier X *first* unlock vs the
tier below it" (marginal grant) -- the "what's new in Pro vs Starter"
view a pricing-page row or upgrade-CTA card uses.

Python helper (clawmetry/entitlements.py):
- tier_unlocks(target_tier) -> dict | None. Returns
  {tier, tier_label, tier_rank, previous_tier, previous_tier_label,
   previous_tier_rank, features, runtimes} with features/runtimes being
  the set difference between this tier's grant and the next-lower
  *purchasable* tier's grant (trial excluded). Floor tiers (OSS /
  Cloud Free at rank 0) collapse to previous_tier=None and the marginal
  reports the full free grant. Unknown / non-purchasable / empty -> None.
  Never raises.

HTTP endpoint (routes/entitlement.py):
- GET /api/entitlement/tier-unlocks?tier=<id>. Sibling of
  /api/entitlement/preview. 400 on missing tier, 404 on unknown tier
  (including trial -- not purchasable), 500 on resolver failure with a
  logged warning.

Tests (tests/test_entitlement_tier_unlocks.py, 24 tests):
- shape pins (full keyset, sorted lists, tier metadata matches target)
- per-tier marginals (oss/cloud_free floor collapses; starter -> paid
  runtimes + STARTER_FEATURES; cloud_pro/pro -> PRO_ONLY_FEATURES, no
  new runtimes; enterprise -> ENTERPRISE_FEATURES)
- safety (trial -> None, unknown -> None, empty/None -> None,
  lowercases input, never raises, doesn't mutate live entitlement)
- catalogue round-trip (union of marginals across purchasable tiers ==
  PAID_FEATURES | ENTERPRISE_FEATURES and PAID_RUNTIMES respectively;
  marginal feature sets disjoint across tiers -- no double-listing)
- API surface (200 for pro/starter, 400 missing, 404 unknown, 404 trial,
  case-insensitive query)

No __version__ bump, no [RELEASE] PR, no enforce flip -- stays in GRACE.
Additive only: /api/entitlement/preview, tier_catalog(), every other
entitlement endpoint untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Jun 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Visual diff

Comparing 032e3db5c856 (head) against the PR base branch.

2 of 64 comparison(s) flagged (>1% pixel diff).

View Before After Diff
desktop overview before after diff · 0.18%
desktop flow before after diff · 0.05%
desktop brain before after diff · 0.01%
desktop usage ⚠️ before after diff · 100.00%
desktop crons before after diff · 0.00%
desktop memory before after diff · 0.00%
desktop security before after diff · 0.00%
desktop subagents before after diff · 0.00%
desktop transcripts before after diff · 0.00%
desktop logs before after diff · 0.00%
desktop skills before after diff · 0.00%
desktop models before after diff · 0.00%
desktop approvals before after diff · 0.02%
desktop alerts before after diff · 0.00%
desktop notifications before after diff · 0.00%
desktop context before after diff · 0.00%
desktop limits before after diff · 0.00%
desktop clusters before after diff · 0.00%
desktop history before after diff · 0.00%
desktop channels ⚠️ before after diff · 1.43%
desktop dives before after diff · 0.00%
desktop harness before after diff · 0.00%
desktop inventory before after diff · 0.00%
desktop nemoclaw before after diff · 0.00%
desktop policy before after diff · 0.00%
desktop selfevolve before after diff · 0.00%
desktop swimlane before after diff · 0.00%
desktop tool-catalog before after diff · 0.00%
desktop tracing before after diff · 0.00%
desktop turn-anatomy before after diff · 0.03%
desktop version-impact before after diff · 0.00%
desktop context-economics before after diff · 0.00%
mobile overview before after diff · 0.11%
mobile flow before after diff · 0.00%
mobile brain before after diff · 0.00%
mobile usage before after diff · 0.00%
mobile crons before after diff · 0.00%
mobile memory before after diff · 0.00%
mobile security before after diff · 0.03%
mobile subagents before after diff · 0.00%
mobile transcripts before after diff · 0.00%
mobile logs before after diff · 0.00%
mobile skills before after diff · 0.00%
mobile models before after diff · 0.00%
mobile approvals before after diff · 0.04%
mobile alerts before after diff · 0.00%
mobile notifications before after diff · 0.00%
mobile context before after diff · 0.00%
mobile limits before after diff · 0.00%
mobile clusters before after diff · 0.00%
mobile history before after diff · 0.00%
mobile channels before after diff · 0.00%
mobile dives before after diff · 0.00%
mobile harness before after diff · 0.00%
mobile inventory before after diff · 0.00%
mobile nemoclaw before after diff · 0.00%
mobile policy before after diff · 0.00%
mobile selfevolve before after diff · 0.00%
mobile swimlane before after diff · 0.00%
mobile tool-catalog before after diff · 0.00%
mobile tracing before after diff · 0.00%
mobile turn-anatomy before after diff · 0.00%
mobile version-impact before after diff · 0.00%
mobile context-economics before after diff · 0.00%

Folder: pr/3228/032e3db5c856. Full PNGs also attached as a workflow artefact.

Generated by visual-diff bot. Pixel diffs >1% flagged; eyeball the table before merging. This check is non-blocking — fail = bot bug, not a code problem.

@vivekchand vivekchand marked this pull request as ready for review June 21, 2026 00:04
@vivekchand vivekchand merged commit 3044d05 into main Jun 21, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants