Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

View 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,
}
}