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.
178 lines
5.7 KiB
Go
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
|
|
}
|