The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
527 lines
18 KiB
Go
527 lines
18 KiB
Go
// 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
|
|
}
|