// Package entitlements resolves which premium agent capabilities a // given subscription plan unlocks — today, specifically dynamic // workflows (dispatch_workflow's parallel multi-sub-agent // orchestration). Effort control and fast mode are deliberately NOT // gated here: per the product brief, those two stay available on every // plan, so there is nothing for this package to decide about them. // // Why this is its own small package rather than a field on billing's // domain.Plan: the agent service never imports billing's domain // package directly (see ARCHITECTURE.md's layering rules — Agent calls // Billing only over gRPC, as a saga coordinator, and billing's // CheckAndIncrementUsageResponse already hands back the plan as a // plain string, PlanName, specifically so a caller doesn't need // billing's own types to make a decision from it). Keeping the // plan-name -> capability mapping here, keyed by that same string, // lets the agent service gate a feature without a new cross-service // dependency, and lets ops retarget which plan names unlock dynamic // workflows via one env var (see DynamicWorkflowPlansEnv) with no code // change — which matters because, as of this package's introduction, // billing's own plan catalog (services/billing/internal/domain/ // subscription.go) only names four tiers (free/basic/pro/premium), not // the Enterprise/Team/Max vocabulary dynamic workflows are meant to // ship on. "premium" — today's top tier — is the default stand-in for // all three until billing's catalog is extended with real Team/ // Enterprise/Max entries (a Catalog append plus a migration touching // the `plan` CHECK constraint — see that file's own doc comment — not // a change this package needs to know about). package entitlements import ( "strings" ) // DynamicWorkflowPlansEnv is the environment variable // cmd/server/main.go reads to build a Set's plan allowlist — a // comma-separated list of plan names (matched case-insensitively // against billing's CheckAndIncrementUsageResponse.PlanName), e.g. // "premium,team,enterprise,max". Unset or empty means "use // DefaultDynamicWorkflowPlans". const DynamicWorkflowPlansEnv = "ENKLY_DYNAMIC_WORKFLOW_PLANS" // DefaultDynamicWorkflowPlans is used when DynamicWorkflowPlansEnv is // unset — "premium" is billing's existing top tier; see the package // doc comment for why that's today's honest stand-in for // Enterprise/Team/Max. var DefaultDynamicWorkflowPlans = []string{"premium"} // Catalog is the resolved plan allowlist — built once at startup (see // ParsePlanList) and consulted per request via Resolve. Kept as a // small value type rather than a package-level global so a test (or a // future per-tenant override) can construct its own without mutating // shared state. type Catalog struct { dynamicWorkflowPlans map[string]bool } // ParsePlanList builds a Catalog from a comma-separated plan list (see // DynamicWorkflowPlansEnv's format); an empty/whitespace-only raw // falls back to DefaultDynamicWorkflowPlans. func ParsePlanList(raw string) Catalog { fields := strings.Split(raw, ",") names := make([]string, 0, len(fields)) for _, f := range fields { if f = strings.ToLower(strings.TrimSpace(f)); f != "" { names = append(names, f) } } if len(names) == 0 { names = DefaultDynamicWorkflowPlans } set := make(map[string]bool, len(names)) for _, n := range names { set[n] = true } return Catalog{dynamicWorkflowPlans: set} } // Set is one user's resolved capabilities for one turn — what // application.Service computes once (from the plan name billing's // CheckAndIncrementUsage already returned) and attaches to the run // context via toolexec.WithEntitlements, for dispatch_workflow to read // back without any gRPC call of its own. type Set struct { // DynamicWorkflows reports whether dispatch_workflow is usable this // turn. DynamicWorkflows bool // PlanName is the plan the decision was made from, carried along // purely so a denial message can name it (e.g. "not available on // the Free plan") without a second lookup. PlanName string } // Resolve decides a Set for planName against c's allowlist. An unknown // or empty planName (a billing lookup that failed open, or a caller // that never set one) resolves as not entitled — a gated feature // failing closed on missing information is the safe default, unlike // the daily-message-quota check in application.Service.StartTurn, // which deliberately fails *open* because getting that wrong costs // someone a chat message, not an unintended unlock of a paid feature. func (c Catalog) Resolve(planName string) Set { return Set{ DynamicWorkflows: c.dynamicWorkflowPlans[strings.ToLower(strings.TrimSpace(planName))], PlanName: planName, } }