feat(entitlements): tier_unlocks(tier) — per-tier marginal unlocks ("what's new at X vs the tier below")#3228
Merged
Merged
Conversation
…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>
Contributor
Visual diffComparing 2 of 64 comparison(s) flagged (>1% pixel diff).
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds the marginal sibling of
preview(tier): wherepreview()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/runtimesare 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 toprevious_tier = nulland the marginal reports the full free grant.None. Never raises.HTTP endpoint (
routes/entitlement.py)GET /api/entitlement/tier-unlocks?tier=<id>— sibling of/api/entitlement/preview.400on missing tier,404on unknown tier (includingtrial— not purchasable),500on 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_FEATURESitself.tier_unlockscloses 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:
previous_tier)features(new at this tier)runtimes(new at this tier)oss/cloud_free(null)FREE_FEATURESFREE_RUNTIMEScloud_starter(←oss)STARTER_FEATURESPAID_RUNTIMEScloud_pro/pro(←cloud_starter)PRO_ONLY_FEATURES[](already unlocked at Starter)enterprise(←cloud_pro)ENTERPRISE_FEATURES[]Not changing
preview(tier)and/api/entitlement/previeware untouched (existing tests still green).tier_catalog()is untouched — adding a marginalunlocksfield per row is intentionally deferred to a follow-up PR; this PR is just the helper + endpoint.__version__bump, no[RELEASE]PR, no enforce-flip — stays in GRACE.Tests
tests/test_entitlement_tier_unlocks.py(24 tests, all green):STARTER_FEATURES; cloud_pro / pro →PRO_ONLY_FEATURES, no new runtimes; enterprise →ENTERPRISE_FEATURES)PAID_FEATURES | ENTERPRISE_FEATURESandPAID_RUNTIMESrespectively; marginal feature sets are disjoint across tiers — no double-listing on the pricing page)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.pyclean.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:
clawmetry/entitlements.py,routes/entitlement.py,tests/test_entitlement_tier_unlocks.pyinto~/.clawmetry/lib/pythonX.Y/site-packages/clawmetry/(and matching paths forroutes/andtests/), clear__pycache__/.launchctl kickstart -k gui/$(id -u)/com.clawmetry.syncto bounce the daemon.curl -s 'http://localhost:8900/api/entitlement/tier-unlocks?tier=cloud_pro' | jq— expectprevious_tier: "cloud_starter",featureslisting the 14 PRO_ONLY features,runtimes: [].curl -s 'http://localhost:8900/api/entitlement/tier-unlocks?tier=cloud_starter' | jq— expectprevious_tier: "oss", every paid runtime inruntimes,featureslisting the 7 STARTER_FEATURES.curl -s 'http://localhost:8900/api/entitlement/tier-unlocks?tier=enterprise' | jq— expectprevious_tier: "cloud_pro",featureslisting the 6 ENTERPRISE_FEATURES,runtimes: [].curl -s 'http://localhost:8900/api/entitlement/tier-unlocks?tier=oss' | jq .previous_tier— expectnullandfeatureslisting the free baseline.curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8900/api/entitlement/tier-unlocks— expect400.curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:8900/api/entitlement/tier-unlocks?tier=trial'— expect404(trial is not purchasable).curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:8900/api/entitlement/tier-unlocks?tier=nonsense_xyz'— expect404.next_tier_difffrom/api/entitlement; the new endpoint is opt-in.🤖 Generated with Claude Code
Generated by Claude Code