Files
member-console/internal/entitlements/materialize.go
T
cgalo5758 f8a3478f2a Rebuild the entitlement set Rules surface as a staged batch
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.
2026-09-19 19:46:09 -05:00

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
}