This commit is contained in:
105
pkg/entitlements/entitlements.go
Normal file
105
pkg/entitlements/entitlements.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user