Files
member-console/internal/entitlements/rule_changes.go
T
cgalo5758 3727ff31d8 Add entitlement set rule change ledger and preview flow
Add an append-only ledger of entitlement set rule changes with per-pool
effect rows, a preview-and-commit rule change flow, and an automatic
drain that settles deferred recomputations. Rules gain a tier reduction
policy, resource keys declare over-limit behavior, and the materializer
now lowers limits when a rule stops applying.
Add entitlement set rule change ledger and preview flow

Add an append-only ledger of entitlement set rule changes with a
preview-and-commit operator flow. Rule writes now go through an enclosed
`core.commit_rule_change` function that files an act row and one
obligation per carrying pool, with a drain workflow settling deferred
recomputations. The preview dry-runs the materializer with a rule
overlay and renders per-pool buckets, reduction-policy disclosures, and
provider over-limit consequences. Materializing transactions take a
shared advisory rendezvous that rule changes hold exclusively, enforced
by a possession assertion. Add History and Entitlement changes surfaces,
a rule-less warning on five product-selection surfaces, and a
`tier_reduction_policy` column that gates FedWiki parking.
2026-09-15 03:53:28 -05:00

178 lines
5.7 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
)
// The four change kinds core.commit_rule_change accepts. rule_reactivated is
// reserved: no console surface writes it in this change.
const (
ChangeKindRuleAdded = "rule_added"
ChangeKindRuleModified = "rule_modified"
ChangeKindRuleDeactivated = "rule_deactivated"
ChangeKindRuleReactivated = "rule_reactivated"
)
// The actor triple's discriminator, matching set_change_actor_coherent.
const (
ActorTypePerson = "person"
ActorTypeServiceAccount = "service_account"
ActorTypeSystem = "system"
)
// ruleChangeKindMismatchMessage is what core.commit_rule_change raises when
// the kind does not fit the row or the edit changes nothing.
const ruleChangeKindMismatchMessage = "rule_change_kind_mismatch"
// RuleFields are the declarative fields of one rule, the p_rule payload of
// core.commit_rule_change. They mirror the writable columns of
// core.entitlement_set_rules; the ids, the timestamps and is_active are the
// function's business, not the caller's.
type RuleFields struct {
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
Description sql.NullString
}
// JSONB renders the fields as the function's p_rule argument. An absent key
// reads as NULL through ->>, which is how a boolean rule carries no limit and
// how an omitted tier_reduction_policy keeps the stored one on an edit.
func (f RuleFields) JSONB() (json.RawMessage, error) {
m := map[string]any{}
if f.RuleType != "" {
m["rule_type"] = f.RuleType
}
if f.ResourceKey.Valid {
m["resource_key"] = f.ResourceKey.String
}
if f.ResourceValue.Valid {
m["resource_value"] = f.ResourceValue.Int64
}
if f.ResourcePerUnit.Valid {
m["resource_per_unit"] = f.ResourcePerUnit.Bool
}
if f.StackingPolicy.Valid {
m["stacking_policy"] = f.StackingPolicy.String
}
if f.ResetPeriod.Valid {
m["reset_period"] = f.ResetPeriod.String
}
if f.TierReductionPolicy != "" {
m["tier_reduction_policy"] = f.TierReductionPolicy
}
if f.CreditAmount.Valid {
m["credit_amount"] = f.CreditAmount.Int32
}
if f.CreditCurrency.Valid {
m["credit_currency"] = f.CreditCurrency.String
}
if f.Description.Valid {
m["description"] = f.Description.String
}
b, err := json.Marshal(m)
if err != nil {
return nil, fmt.Errorf("render rule fields: %w", err)
}
return json.RawMessage(b), nil
}
// CommitRuleChangeInput is one rule change: the set, the kind, the rule it
// names (empty on an add), the proposed fields and the actor triple.
type CommitRuleChangeInput struct {
SetID string
ChangeKind string
RuleID string
Rule RuleFields
ActorType string
ActorPersonID string
ActorServiceAccountID string
Note string
RequestID string
EffectiveAt time.Time
SyncCap int32
}
// CommitRuleChangeTx writes one rule change through core.commit_rule_change,
// the only write path to core.entitlement_set_rules: the rule row, the act row
// and one pending obligation per carrying pool, all in the caller's
// transaction. That transaction must hold the exclusive materialization
// rendezvous, which the function asserts, so it must come from BeginRuleChange
// and q must be bound to it. The function never materializes: each obligation
// is settled through SettleObligation.
func CommitRuleChangeTx(ctx context.Context, q *Queries, in CommitRuleChangeInput) (CommitRuleChangeRow, error) {
rule, err := in.Rule.JSONB()
if err != nil {
return CommitRuleChangeRow{}, err
}
ruleID, err := nullUUID(in.RuleID)
if err != nil {
return CommitRuleChangeRow{}, fmt.Errorf("rule id: %w", err)
}
personID, err := nullUUID(in.ActorPersonID)
if err != nil {
return CommitRuleChangeRow{}, fmt.Errorf("actor person id: %w", err)
}
serviceAccountID, err := nullUUID(in.ActorServiceAccountID)
if err != nil {
return CommitRuleChangeRow{}, fmt.Errorf("actor service account id: %w", err)
}
effectiveAt := in.EffectiveAt
if effectiveAt.IsZero() {
effectiveAt = time.Now()
}
syncCap := in.SyncCap
if syncCap == 0 {
syncCap = rematerializeSyncCap
}
return q.CommitRuleChange(ctx, CommitRuleChangeParams{
SetID: in.SetID,
ChangeKind: in.ChangeKind,
RuleID: ruleID,
Rule: rule,
ActorType: in.ActorType,
ActorPersonID: personID,
ActorServiceAccountID: serviceAccountID,
Note: sql.NullString{String: in.Note, Valid: in.Note != ""},
RequestID: sql.NullString{String: in.RequestID, Valid: in.RequestID != ""},
EffectiveAt: effectiveAt,
SyncCap: syncCap,
})
}
func nullUUID(s string) (uuid.NullUUID, error) {
if s == "" {
return uuid.NullUUID{}, nil
}
parsed, err := uuid.Parse(s)
if err != nil {
return uuid.NullUUID{}, err
}
return uuid.NullUUID{UUID: parsed, Valid: true}, nil
}
// IsRuleChangeKindMismatch reports whether err is the function's refusal that
// the kind does not fit the row: a deactivation of an inactive rule, an edit
// that changes nothing, or a rule id that is not on the set.
func IsRuleChangeKindMismatch(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Message == ruleChangeKindMismatchMessage
}