// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package entitlements import ( "context" "database/sql" "fmt" "sort" ) // PoolReader is the read-only slice of Queries the compute step needs. The // compute step takes this interface rather than *Queries so that no path // through it can write. type PoolReader interface { GetActivePoolProvisionsByPoolID(ctx context.Context, poolID string) ([]PoolProvision, error) GetActiveRulesBySetID(ctx context.Context, setID string) ([]EntitlementSetRule, error) ListNumericEntitlementsByPoolID(ctx context.Context, poolID string) ([]NumericEntitlement, error) ListBooleanEntitlementsByPoolID(ctx context.Context, poolID string) ([]BooleanEntitlement, error) } // RuleOverlay is the proposed declarative fields of exactly one rule, // substituted for that rule's stored row while a pool is folded. A nil RuleID // is a rule being added; IsActive false is a deactivation and needs a RuleID. // SetID names the set the rule belongs to, so the overlay reaches only that // set's provisions; a RuleID must be a rule of SetID, which the caller // verifies before it previews, because the fold reads only active rules. type RuleOverlay struct { RuleID *string SetID string RuleType string ResourceKey sql.NullString ResourceValue sql.NullInt64 ResourcePerUnit sql.NullBool StackingPolicy sql.NullString ResetPeriod sql.NullString TierReductionPolicy string CreditAmount sql.NullInt32 CreditCurrency sql.NullString IsActive bool } // KeyState is one resource key's would-be state on a pool. A boolean key // carries IsEnabled; a numeric key carries ResourceLimit and, beside it, the // reduction policy governing the key on this pool: the strongest across the // rules funding it, which is the pool's policy rather than any one rule's. // It is empty on a key no rule funds and on a boolean key, because the // policy is a limit rule's field. type KeyState struct { ResourceKey string Boolean bool IsEnabled bool ResourceLimit int64 GoverningPolicy string } type contribution struct { provisionID string contributedValue int64 stackingPolicy string tierReductionPolicy string } // reductionPolicyOrder is the one ordered list of the entitlements spec ("The // reduction policy governing a pool and key is the strongest across the rules // funding it"), strongest first. GetGoverningReductionPolicy states the same // order as an explicit SQL CASE; nothing compares these values as text. var reductionPolicyOrder = [4]string{"force_reduce", "clamp", "block", "defer"} // policyRank is a policy's position in that list. A value outside the list // ranks last, so it never outranks a stated one. func policyRank(policy string) int { for i, stated := range reductionPolicyOrder { if stated == policy { return i } } return len(reductionPolicyOrder) } // governingPolicy is the strongest policy among the contributions funding one // key on one pool. It is empty when no contribution carries a stated policy, // which is what leaves the effect row's governing column NULL. func governingPolicy(contribs []contribution) string { governing := "" best := len(reductionPolicyOrder) for _, c := range contribs { if rank := policyRank(c.tierReductionPolicy); rank < best { best, governing = rank, c.tierReductionPolicy } } return governing } // poolFold is a pool's active provisions and their active rules folded into // the numeric contributions per resource key and the boolean keys at least // one active provision carries. The compute step produces it; the apply step // and the dry run both consume it, so there is one fold. type poolFold struct { contributionsByResource map[string][]contribution activeBooleanKeys map[string]bool } // governingPolicies is the fold's answer per resource key, which the // settlement stamps on every effect row it writes for that key. A key no // contribution funds is absent, so its effect row carries no governing // policy. func (f poolFold) governingPolicies() map[string]string { out := make(map[string]string, len(f.contributionsByResource)) for resourceKey, contribs := range f.contributionsByResource { if policy := governingPolicy(contribs); policy != "" { out[resourceKey] = policy } } return out } // MaterializePoolEntitlements performs a full re-evaluation of all active provisions // on a pool, producing numeric entitlements, contributions, and usage records for // limit rules and boolean_entitlements rows for boolean rules. A numeric key that // no active rule feeds any more is written to resource_limit = 0 with its // entitlement row and usage row retained; a boolean key nobody carries lapses. // It is the compute step followed by the apply step. // This function must be called within an existing transaction. func MaterializePoolEntitlements(ctx context.Context, q *Queries, poolID string) error { _, err := materializePool(ctx, q, poolID) return err } // materializePool is MaterializePoolEntitlements returning the fold's // governing reduction policy per resource key, so a settlement stamps its // effect rows from the fold it already computed rather than folding twice. func materializePool(ctx context.Context, q *Queries, poolID string) (map[string]string, error) { fold, err := computePoolFold(ctx, q, poolID, nil) if err != nil { return nil, err } if err := applyPoolFold(ctx, q, poolID, fold); err != nil { return nil, err } return fold.governingPolicies(), nil } // DryRunPoolEntitlements folds the pool with each overlay substituted for its // rule's stored row and returns the would-be per-key state, sorted by resource // key, including every key the pool holds today that would fall to 0 or lapse. // The overlays are a staged batch, at most one per resource key, so the pool // is folded once against the whole would-be ruleset; a change to one rule is // the one-element list. It writes nothing, takes no lock and never runs the // apply step. func DryRunPoolEntitlements(ctx context.Context, r PoolReader, poolID string, overlays []RuleOverlay) ([]KeyState, error) { seen := make(map[string]bool, len(overlays)) for _, overlay := range overlays { if !overlay.IsActive && overlay.RuleID == nil { return nil, fmt.Errorf("dry run for pool %s: a deactivation overlay needs a rule id", poolID) } key := overlay.ResourceKey.String if key == "" { continue } if seen[key] { return nil, fmt.Errorf("dry run for pool %s: two overlays for resource key %s", poolID, key) } seen[key] = true } fold, err := computePoolFold(ctx, r, poolID, overlays) if err != nil { return nil, err } return wouldBeState(ctx, r, poolID, fold) } // computePoolFold reads the pool's active provisions and each one's active // rules, with the overlays substituted where they apply, and folds them. func computePoolFold(ctx context.Context, r PoolReader, poolID string, overlays []RuleOverlay) (poolFold, error) { fold := poolFold{ contributionsByResource: make(map[string][]contribution), activeBooleanKeys: make(map[string]bool), } provisions, err := r.GetActivePoolProvisionsByPoolID(ctx, poolID) if err != nil { return fold, fmt.Errorf("get active provisions: %w", err) } for _, prov := range provisions { rules, err := r.GetActiveRulesBySetID(ctx, prov.EntitlementSetID) if err != nil { return fold, fmt.Errorf("get rules for entitlement set %s: %w", prov.EntitlementSetID, err) } rules = overlayRules(rules, prov.EntitlementSetID, overlays) for _, rule := range rules { if rule.RuleType == "boolean" { if rule.ResourceKey.Valid { fold.activeBooleanKeys[rule.ResourceKey.String] = true } continue } if rule.RuleType != "limit" { continue // quota/credit rules are not materialized yet } resourceKey := rule.ResourceKey.String baseValue := rule.ResourceValue.Int64 // Apply per-unit multiplier value := baseValue if rule.ResourcePerUnit.Valid && rule.ResourcePerUnit.Bool { value = baseValue * int64(prov.Quantity) } policy := "additive" if rule.StackingPolicy.Valid { policy = rule.StackingPolicy.String } fold.contributionsByResource[resourceKey] = append( fold.contributionsByResource[resourceKey], contribution{ provisionID: prov.ProvisionID, contributedValue: value, stackingPolicy: policy, tierReductionPolicy: rule.TierReductionPolicy, }, ) } } return fold, nil } // overlayRules substitutes the batch's overlays for their stored rows among // one set's active rules: every stored row an overlay names drops out, and // each active overlay joins the list. Sets no overlay names are returned // unchanged, so a batch on one set reaches that set's provisions alone. func overlayRules(rules []EntitlementSetRule, setID string, overlays []RuleOverlay) []EntitlementSetRule { applies := false for _, overlay := range overlays { if overlay.SetID == setID { applies = true break } } if !applies { return rules } replaced := make(map[string]bool, len(overlays)) for _, overlay := range overlays { if overlay.SetID == setID && overlay.RuleID != nil { replaced[*overlay.RuleID] = true } } out := make([]EntitlementSetRule, 0, len(rules)+len(overlays)) for _, rule := range rules { if replaced[rule.RuleID] { continue } out = append(out, rule) } for _, overlay := range overlays { if overlay.SetID != setID || !overlay.IsActive { continue } var ruleID string if overlay.RuleID != nil { ruleID = *overlay.RuleID } out = append(out, EntitlementSetRule{ RuleID: ruleID, SetID: setID, RuleType: overlay.RuleType, ResourceKey: overlay.ResourceKey, ResourceValue: overlay.ResourceValue, ResourcePerUnit: overlay.ResourcePerUnit, StackingPolicy: overlay.StackingPolicy, ResetPeriod: overlay.ResetPeriod, TierReductionPolicy: overlay.TierReductionPolicy, CreditAmount: overlay.CreditAmount, CreditCurrency: overlay.CreditCurrency, IsActive: true, }) } return out } // effectiveLimit applies each contribution's stacking policy (additive for // this milestone; maximum keeps the largest) to reach the key's limit. func effectiveLimit(contribs []contribution) int64 { var limit int64 for _, c := range contribs { switch c.stackingPolicy { case "additive": limit += c.contributedValue case "maximum": if c.contributedValue > limit { limit = c.contributedValue } } } return limit } // wouldBeState renders a fold as the per-key state the apply step would // leave: fed numeric keys at their effective limit, the pool's other limit // entitlements at 0, carried boolean keys enabled, the pool's other boolean // rows disabled. func wouldBeState(ctx context.Context, r PoolReader, poolID string, fold poolFold) ([]KeyState, error) { type stateKey struct { resourceKey string boolean bool } states := make(map[stateKey]KeyState) for resourceKey, contribs := range fold.contributionsByResource { states[stateKey{resourceKey, false}] = KeyState{ ResourceKey: resourceKey, ResourceLimit: effectiveLimit(contribs), GoverningPolicy: governingPolicy(contribs), } } existingNumerics, err := r.ListNumericEntitlementsByPoolID(ctx, poolID) if err != nil { return nil, fmt.Errorf("list numeric entitlements: %w", err) } for _, ent := range existingNumerics { if _, fed := fold.contributionsByResource[ent.ResourceKey]; fed { continue } if ent.EntitlementType != "limit" { continue } states[stateKey{ent.ResourceKey, false}] = KeyState{ResourceKey: ent.ResourceKey, ResourceLimit: 0} } for resourceKey := range fold.activeBooleanKeys { states[stateKey{resourceKey, true}] = KeyState{ResourceKey: resourceKey, Boolean: true, IsEnabled: true} } existingBooleans, err := r.ListBooleanEntitlementsByPoolID(ctx, poolID) if err != nil { return nil, fmt.Errorf("list boolean entitlements: %w", err) } for _, be := range existingBooleans { if fold.activeBooleanKeys[be.ResourceKey] { continue } states[stateKey{be.ResourceKey, true}] = KeyState{ResourceKey: be.ResourceKey, Boolean: true, IsEnabled: false} } out := make([]KeyState, 0, len(states)) for _, st := range states { out = append(out, st) } sort.Slice(out, func(i, j int) bool { if out[i].ResourceKey != out[j].ResourceKey { return out[i].ResourceKey < out[j].ResourceKey } return !out[i].Boolean && out[j].Boolean }) return out, nil } // applyPoolFold reconciles the pool's rows to the fold. It first asserts that // the transaction holds the materialization rendezvous (A17), then visits keys // in sorted order so that a transaction materializing many pools takes their // entitlement rows in one order. func applyPoolFold(ctx context.Context, q *Queries, poolID string, fold poolFold) error { if err := assertRendezvous(ctx, q.db, poolID); err != nil { return err } // 1. For each resource key that at least one active rule feeds, ensure the // entitlement, its contributions and its usage row exist, then write the // effective limit. resourceKeys := make([]string, 0, len(fold.contributionsByResource)) for resourceKey := range fold.contributionsByResource { resourceKeys = append(resourceKeys, resourceKey) } sort.Strings(resourceKeys) for _, resourceKey := range resourceKeys { contribs := fold.contributionsByResource[resourceKey] // Get or create the numeric entitlement ent, err := q.GetNumericEntitlementByPoolAndResource(ctx, GetNumericEntitlementByPoolAndResourceParams{ PoolID: poolID, ResourceKey: resourceKey, }) if err == sql.ErrNoRows { ent, err = q.CreateNumericEntitlement(ctx, CreateNumericEntitlementParams{ PoolID: poolID, ResourceKey: resourceKey, EntitlementType: "limit", ResourceLimit: 0, }) if err != nil { return fmt.Errorf("create numeric entitlement for %s: %w", resourceKey, err) } } else if err != nil { return fmt.Errorf("get numeric entitlement for %s: %w", resourceKey, err) } // Replace this entitlement's contributions with the fold's if err := deleteEntitlementContributions(ctx, q, ent.EntitlementID); err != nil { return err } for _, c := range contribs { _, err := q.CreateNumericEntitlementContribution(ctx, CreateNumericEntitlementContributionParams{ EntitlementID: ent.EntitlementID, ProvisionID: c.provisionID, ContributedValue: c.contributedValue, StackingPolicy: c.stackingPolicy, }) if err != nil { return fmt.Errorf("create contribution: %w", err) } } // Update the entitlement's resource_limit _, err = q.UpdateNumericEntitlementLimit(ctx, UpdateNumericEntitlementLimitParams{ EntitlementID: ent.EntitlementID, ResourceLimit: effectiveLimit(contribs), }) if err != nil { return fmt.Errorf("update entitlement limit: %w", err) } // Ensure usage record exists _, err = q.GetUsageByPoolAndResource(ctx, GetUsageByPoolAndResourceParams{ PoolID: poolID, ResourceKey: resourceKey, }) if err == sql.ErrNoRows { _, err = q.CreateNumericEntitlementUsage(ctx, CreateNumericEntitlementUsageParams{ EntitlementID: ent.EntitlementID, PoolID: poolID, ResourceKey: resourceKey, }) if err != nil { return fmt.Errorf("create usage record: %w", err) } } else if err != nil { return fmt.Errorf("get usage record: %w", err) } } // 2. Zero every numeric entitlement that no active rule on an active // provision feeds any more, whether its provisions ended or its set lost // the rule. The entitlement row and its usage row are retained, so usage // history survives and a later rule add reactivates the key in place. existingNumerics, err := q.ListNumericEntitlementsByPoolID(ctx, poolID) if err != nil { return fmt.Errorf("list numeric entitlements: %w", err) } for _, ent := range existingNumerics { if _, fed := fold.contributionsByResource[ent.ResourceKey]; fed { continue } if ent.EntitlementType != "limit" { continue // only limit entitlements are materialized here } if err := deleteEntitlementContributions(ctx, q, ent.EntitlementID); err != nil { return err } if ent.ResourceLimit == 0 { continue } if _, err := q.UpdateNumericEntitlementLimit(ctx, UpdateNumericEntitlementLimitParams{ EntitlementID: ent.EntitlementID, ResourceLimit: 0, }); err != nil { return fmt.Errorf("zero entitlement limit for %s: %w", ent.ResourceKey, err) } } // 3. Materialize boolean rules: granted iff at least one active provision // currently carries the rule. Lapsed keys are updated to granted = FALSE // but never created; absence of a row means the key was never conferred // on this pool. existingBooleans, err := q.ListBooleanEntitlementsByPoolID(ctx, poolID) if err != nil { return fmt.Errorf("list boolean entitlements: %w", err) } for _, be := range existingBooleans { if be.Granted && !fold.activeBooleanKeys[be.ResourceKey] { if _, err := q.LapseBooleanEntitlement(ctx, LapseBooleanEntitlementParams{ PoolID: poolID, ResourceKey: be.ResourceKey, }); err != nil { return fmt.Errorf("lapse boolean entitlement for %s: %w", be.ResourceKey, err) } } } booleanKeys := make([]string, 0, len(fold.activeBooleanKeys)) for resourceKey := range fold.activeBooleanKeys { booleanKeys = append(booleanKeys, resourceKey) } sort.Strings(booleanKeys) for _, resourceKey := range booleanKeys { if _, err := q.UpsertBooleanEntitlementGranted(ctx, UpsertBooleanEntitlementGrantedParams{ PoolID: poolID, ResourceKey: resourceKey, }); err != nil { return fmt.Errorf("upsert boolean entitlement for %s: %w", resourceKey, err) } } return nil } // deleteEntitlementContributions removes every contribution row of one // numeric entitlement, one provision at a time, and leaves the same // provisions' rows on the pool's other entitlements alone. func deleteEntitlementContributions(ctx context.Context, q *Queries, entitlementID string) error { existing, err := q.ListContributionsByEntitlementID(ctx, entitlementID) if err != nil { return fmt.Errorf("list contributions for entitlement %s: %w", entitlementID, err) } for _, ec := range existing { if err := q.DeleteContributionsByProvisionID(ctx, DeleteContributionsByProvisionIDParams{ EntitlementID: entitlementID, ProvisionID: ec.ProvisionID, }); err != nil { return fmt.Errorf("delete contributions for provision %s on entitlement %s: %w", ec.ProvisionID, entitlementID, err) } } return nil }