docs: add entitlement tier boundaries proposal#2144
Conversation
Defines clear capability boundaries for all four tiers (PRE_ONBOARD, PLG, FREE_TRIAL, PAID) — focusing on PLG vs FREE_TRIAL differentiation including site count and audit run quotas. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Callers should migrate to entitlement.getTier() === Entitlement.TIERS.PLG directly once PLG tier is live in production. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
This PR will trigger no release when merged. |
solaris007
left a comment
There was a problem hiding this comment.
Hey @ravverma,
Thanks for writing this down - the PLG vs FREE_TRIAL ambiguity has been an implicit fault line, and putting boundaries on paper is exactly the right move. That said, the proposal has factual issues that need resolving before it merges, and the overall placement should probably shift to mysticat-architecture. Details below.
Strengths
- Section 1.1's "what the code actually does today" table is the right shape for a proposal. Anchoring the discussion in current state (not wish list) keeps everyone grounded.
- Section 3's boundary-contrast tables (PLG vs FREE_TRIAL, PLG vs PAID) are the fastest way for any reader to absorb the intent. Under a minute to skim.
- Section 3.1's PRE_ONBOARD framing as "preparation state, not a product state" is a cleaner conceptual model than anything in the current code.
- Section 5 separating "concept" from "code alignment gaps" preserves the doc's claim that this is a proposal and not an implementation plan. That discipline matters.
- Section 6's open questions (OQ-2 on downgrade semantics, OQ-4 on retroactive migration) are the kind of product questions that block implementation and are easy to forget. Surfacing them is a service to the next reader.
- Naming
getIsSummitPlgEnableda transitional shim in the doc itself (not in a side channel) sets the right precedent. Proposals should name their own debt.
Issues
Critical (Must Fix)
1. Multiple "current state" claims in Sections 1 and 5 do not match main.
I verified the referenced files directly against current main. Four claims describe changes that are already shipped:
- Section 1 observation 2 and Section 5 row 2 (
utils.js line 627): claim saysgetIsSummitPlgEnabled"checks FREE_TRIAL today." Actual code atsrc/support/utils.js:653:return entitlement?.getTier() === EntitlementModel.TIERS.PLG;. Already PLG. - Section 1 observation 3 and Section 5 row 3 (
plg-onboarding.js line 53): claim says the PLG onboarding flow "creates a FREE_TRIAL entitlement." Actualsrc/controllers/plg/plg-onboarding.js:52:const ASO_TIER = EntitlementModel.TIERS.PLG;used increateEntitlement(ASO_TIER). Already PLG. - Section 1 observation 1 and Section 5 row 1 (
login.js line 202-204): claim says sub-services are injected "only for FREE_TRIAL." Actualspacecat-auth-service/src/ims/login.js(note: different repo) already injects for bothFREE_TRIAL || PLG. Already shipped. - Section 5 last row (
CUSTOMER_VISIBLE_TIERS): claim says "Contains FREE_TRIAL, PAID only." Actualsrc/support/utils.js:1950-1954already includesFREE_TRIAL, PAID, PLG. Already shipped.
Why this matters: this doc is a handoff artifact. If an engineer reads Section 5 and files PRs against it, four of those PRs will be no-ops or regressions. The credibility of the remaining rows drops with it.
Fix: re-verify every "current state" claim against main before re-review. Rewrite Sections 1 and 5 to reflect actual gaps. Separate "already shipped" from "still open" clearly. If the gap list shrinks to two or three items, say so - that is a positive outcome, not something to hide. Consider adding a "Verified against commit " line so staleness becomes visible in future reads.
2. Section 3.2's "transitional shim" guidance would remove functionality if followed literally.
The proposal (Section 3.2 and Section 5 row 2) claims that getIsSummitPlgEnabled "is equivalent to entitlement.getTier() === PLG" and that callers should "migrate to a direct tier check."
The function in src/support/utils.js lines 620-658 does four things, not one:
- Gates on
x-client-type: sites-optimizer-uiheader (line 624) - returns false for other client types. - Short-circuits to
truewhenisViewAsTrialRequest(requestContext)matches (lines 627-629). - Requires
Configuration.isHandlerEnabledForSite('summit-plg', site)(line 639) - returns false if disabled. - Only then checks
entitlement?.getTier() === PLG(line 653).
A caller that replaces getIsSummitPlgEnabled() with entitlement.getTier() === PLG loses the client-type gate, the view-as-trial behavior, and the per-site handler toggle. That is a silent feature rollback, not a shim removal.
Fix: either keep the helper and describe what it actually does (PLG + per-site config + view-as-trial + client-type), or spell out which of the four conditions are intended to be inlined and which are dropped. Do not frame as "equivalent." If the helper is genuinely meant to go away, use @deprecated JSDoc plus a lint rule rather than a prose commitment (see Minor 11).
Important (Should Fix)
3. Placement: this belongs in mysticat-architecture, not spacecat-api-service/docs/.
Applying the 70% rule from mysticat-architecture/DOCUMENTATION-GUIDE.md:
The proposal defines a contract whose surface crosses at least three repos even though day-to-day enforcement lives in spacecat-api-service:
login.jsis inadobe/spacecat-auth-service, not api-servicetier-client.jsis inadobe/spacecat-shared/packages/spacecat-shared-tier-client, not api-serviceEntitlementmodel andTIERSconstants live inspacecat-shared-data-accessisSummitPlgEnabledtravels in the JWT and is consumed by UI and other servicesmax_enrolled_sitesandmax_audit_runs_per_dayare quota fields on the entitlement, read by any service that checks entitlements
Sections 1-4 and 6 are concept and contract (platform level). Section 5 is the only block that is implementation focused, and it is explicitly scoped as "gaps" rather than the proposal itself. That is 80%+ platform content by the 70% rule. The open questions are product and platform decisions, not api-service implementation choices.
Recommendation: move to mysticat-architecture/platform/concepts/entitlement-tiers.md (YAML frontmatter format per mysticat-architecture/CLAUDE.md). Two callouts:
mysticat-architecture/products/aso/tier.mdalready exists with a completely different tier concept (scan frequency: Basic, Professional, Enterprise). Pick a non-colliding filename (entitlement-tiers.md, nottiers.md). Same team, same repo, two meanings of "tier" is exactly the kind of confusion this proposal is trying to resolve at the code level.- If you want a pointer from spacecat-api-service, leave a one-line stub in
docs/linking up, not a copy.
4. File references are not repo-qualified.
Sections 1 and 5 reference login.js, tier-client.js, utils.js, plg-onboarding.js, access-control-util.js as if they all live in this PR's repo. Two do not:
login.jsis inadobe/spacecat-auth-servicetier-client.jsis inadobe/spacecat-shared/packages/spacecat-shared-tier-client
A downstream engineer reading Section 5 today has no way to know that two of the nine code-alignment items require PRs against different repos. This is a navigation bug that will cost time. Fix: qualify every file reference with its repo (e.g., spacecat-auth-service/src/ims/login.js:201), or add a "Files referenced" legend at the top of Section 1.
5. Quotas on tier couple two dimensions that will eventually split.
Section 3.2 and Section 5 write max_enrolled_sites and max_audit_runs_per_day as tier derived: "Add these quota fields when tier is PLG." That works today but will be a liability within 12 months:
- A PAID customer running a pilot needs PLG-like caps. Answer today is either "upgrade to PAID with no cap" (cap lost) or "leave on PLG" (billing wrong).
- A FREE_TRIAL customer in a promo needs extended quotas. Same dilemma.
- The first time sales asks for "PAID with a 5-site cap during ramp-up," the tier-as-quota-source model breaks.
Section 3.2 already half says the right thing: "Enforced at enrollment creation time... check entitlement.getQuotas().max_enrolled_sites." The enforcement is tier agnostic. Make the creation-time logic match: tier is a default quota policy, but quota fields are independent and enforcement never reads tier directly.
Fix: reword Section 5 from "Add these quota fields when tier is PLG" to "Apply the tier's default quota policy at creation; enforcement reads quota fields, not tier." Small change, significant posture shift. Keeps future code from sprouting if (tier === 'PLG') { applyCaps() } checks that will be painful to unwind.
6. max_audit_runs_per_day enforcement model has cost and race issues that should be acknowledged.
"Scan recent Audit records for the site within a rolling 24h window" (Section 3.2) is a read-heavy gate whose cost profile is not named:
- The Audit table grows unboundedly per site. Enforcement scan cost scales with site age unless the query is bounded by an index on
(site_id, created_at DESC)with an explicit limit. - Two near-simultaneous triggers can both read count=N and both pass, producing more runs than the cap allows. Without a transactional guard or atomic counter, the cap is advisory under concurrency.
- "Audit trigger layer" is not a single code path. Section 3.2 says "customer-triggered on-demand runs," which is the right scope. The doc should also state that any code path creating an
Auditrecord must either classify as on-demand (goes through the gate) or scheduled (bypasses). Ambiguity here is how caps get silently bypassed.
The doc does not need implementation answers. It should name the architectural expectation: "Enforcement is a single choke point classified on-demand vs scheduled at the API boundary. Workers triggering audits do not pass through this gate." Otherwise the next reader builds it in two places with different logic.
7. Tier transitions are "overwrite" with no stated history strategy.
Section 3.2 exit: "entitlement tier is overwritten." Simplest possible state model, but it throws away the transition timeline:
- A customer disputes their conversion date. Not answerable from entitlement state alone.
- Finance asks "average PLG-to-PAID conversion window." Not computable from current state.
- Support needs to know whether a customer was ever on FREE_TRIAL. Impossible to reconstruct.
Not a blocker, but the doc should make the choice explicit: either (a) accept "tier is current state only; transitions are recoverable from downstream systems (billing, IMS product context history, TrialUser table)" and name the source of truth, or (b) flag entitlement_tier_transitions logging as a follow-up requirement. OQ-2 covers downgrade direction but not this broader history concern.
Minor (Nice to Have)
8. Line number precision for utils.js line 627. The proposal cites line 627 for getIsSummitPlgEnabled. Line 627 in current main is an isViewAsTrialRequest call inside the function body. The function is defined at line 620 and the tier check is at line 653. Use the function-definition line or the specific check line, not one in between.
9. Filename convention: snake_case vs kebab-case. tier_boundaries_proposal.md is snake_case. The rest of docs/ is mostly kebab-case (consumers-api-backlog.md, llmo-http-onboard-notes.md, onboard-workflow.md, sentiment-guidelines-spec.md). The lone snake_case precedent is plg_tier_requirement_and_design.md. Worth renaming to entitlement-tiers.md (kebab-case) when the doc moves.
10. PRE_ONBOARD is a lifecycle state, not a tier. Every other tier answers "what does the customer get?" PRE_ONBOARD answers "has provisioning finished?" Same column, different concept. The single-column model is defensible for current scope, but acknowledge this is a modeling choice: overloading the tier column for simplicity, at the cost of not being able to represent "PAID but still provisioning" cleanly. One paragraph saves a future architect from rediscovering the decision.
11. Shim exit condition is prose, not a plan. "Will be removed once all callers migrate" (Section 3.2) is aspirational. Shims scheduled for removal accrete callers. Make the exit concrete: caller count today, deprecation mechanism (@deprecated + lint rule or a log warning on call), and a target release that deletes it. Without this, in 18 months this line reads the same and the shim has seven more callers.
Recommendations
- Add YAML frontmatter when this moves to mysticat-architecture (
status: proposed,last_verified: YYYY-MM-DD,scope: platform). Makes staleness and decision status visible. - Add a preamble that disambiguates "entitlement tier" (this doc) from "scan/product tier" (the existing
mysticat-architecture/products/aso/tier.md). Same word, two concepts, same team. - Consider merging OQ-6 and OQ-7 into the quota-policy discussion under issue 5. If quotas become an independent dimension with a tier-default policy, those OQs become "what is the default policy for PLG?" which is a simpler question.
Assessment
Ready to merge? No, with fixes.
Reasoning: the factual errors in Sections 1 and 5 describe wrong current state for four distinct code locations, and the "transitional shim equivalence" guidance would cause a silent feature rollback if followed. Fix the current-state claims, fix the shim guidance, move the doc to mysticat-architecture, and this becomes a solid foundation for the Section 6 product decisions.
Next Steps
- Re-verify Sections 1 and 5 against current
main- expect the gap list to shrink. - Rewrite the "transitional shim" paragraph to describe what the helper actually does, and commit to a concrete deprecation path.
- Move the doc to
mysticat-architecture/platform/concepts/entitlement-tiers.mdwith YAML frontmatter. - Rewrite Section 5 to use repo-qualified file references and the tier-as-default-policy framing.
- Address the open questions (OQ-1 through OQ-7) in a follow-up round before downstream implementers pick them up.
| injected into the JWT when there is no IMS product context. `PLG` gets nothing if there is | ||
| no product context — it would behave identically to a paid org with no license. | ||
|
|
||
| 2. **`utils.js` line 627**: `getIsSummitPlgEnabled` checks `FREE_TRIAL` today. This is the |
There was a problem hiding this comment.
Stale. src/support/utils.js:653 on current main already reads return entitlement?.getTier() === EntitlementModel.TIERS.PLG;. See Critical 1 in the top-level review. Separately, the 'transitional shim' framing in this paragraph is inaccurate - the helper also gates on client-type header, view-as-trial, and per-site handler config. See Critical 2.
| customers, all callers should migrate to checking `entitlement.getTier() === Entitlement.TIERS.PLG` | ||
| directly and the function should be removed. | ||
|
|
||
| 3. **`plg-onboarding.js` line 53**: The PLG onboarding flow creates a `FREE_TRIAL` entitlement, |
There was a problem hiding this comment.
Stale. src/controllers/plg/plg-onboarding.js:52 already sets const ASO_TIER = EntitlementModel.TIERS.PLG; and passes it to createEntitlement. See Critical 1 in the top-level review.
|
|
||
| **Capability boundary (proposed)**: | ||
| - Customer-facing. Visible in all API responses. | ||
| - JWT tenant included. Customer can log in. |
There was a problem hiding this comment.
The 'equivalent to entitlement.getTier() === PLG' framing is not accurate. getIsSummitPlgEnabled gates on client-type header, view-as-trial, and per-site handler config in addition to the tier check. Inlining the tier check drops three orthogonal protections and constitutes a silent feature rollback, not a shim removal. See Critical 2 in the top-level review.
Summary
docs/tier_boundaries_proposal.md— a design proposal defining clear, non-overlapping capability boundaries for all four entitlement tiers:PRE_ONBOARD,PLG,FREE_TRIAL, andPAIDisSummitPlgEnabled = true(PLG-specific summit UX) — via a transitional shim scheduled for removal:getIsSummitPlgEnabled()inutils.jsis a temporary compatibility function equivalent toentitlement.getTier() === Entitlement.TIERS.PLG; once PLG is live, all callers should migrate to a direct tier check and the function deletedTrialUserlifecycle tracking (different acquisition motion)max_enrolled_sitesquota — PLG orgs capped at N sites (proposed: 1)max_audit_runs_per_dayquota — on-demand audit triggers capped per site per day (proposed: 3); scheduled worker runs unaffected🤖 Generated with Claude Code