106 lines
4.7 KiB
Go
106 lines
4.7 KiB
Go
// 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,
|
|
}
|
|
}
|