Files
member-console/internal/entitlements/conferral.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

351 lines
12 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package entitlements
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
)
// The conferral primitive lives in five SECURITY DEFINER functions (migration
// 00005) that are the sole legal writers of core.pool_provisions,
// core.pool_provision_ladders, and core.pool_provision_transitions. This file
// is the single Go boundary that calls them: thin typed wrappers plus the one
// place that maps the functions' custom SQLSTATEs to domain errors. sqlc could
// not model the TABLE-returning signatures (design D1 fallback), so the calls
// are hand-written on the same *Queries/tx pattern; the logic stays in SQL.
// Domain errors for the four named conferral rejections. Handlers compare with
// errors.Is and surface them as field/form errors per the db-error-presentation
// convention (design D3).
var (
ErrConferralPrecedesIncumbent = errors.New("conferral activation precedes the incumbent it would supersede")
ErrConferralShapeDiverged = errors.New("conferral shape diverges from the product's current shape; align first")
ErrConferralShapeCollision = errors.New("conferral shape alignment collides with a rung held by another provision")
ErrConferralAlreadyEnded = errors.New("conferral is already ended")
)
// mapConferralError translates the conferral functions' stable custom SQLSTATEs
// (CF001-CF004) into the exported domain sentinels; other errors pass through.
func mapConferralError(err error) error {
if err == nil {
return nil
}
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return err
}
switch pgErr.Code {
case "CF001":
return fmt.Errorf("%w: %s", ErrConferralPrecedesIncumbent, pgErr.Message)
case "CF002":
return fmt.Errorf("%w: %s", ErrConferralShapeDiverged, pgErr.Message)
case "CF003":
return fmt.Errorf("%w: %s", ErrConferralShapeCollision, pgErr.Message)
case "CF004":
return fmt.Errorf("%w: %s", ErrConferralAlreadyEnded, pgErr.Message)
default:
return err
}
}
// Actor identifies who or what drives a conferral change; forwarded to the
// conferral functions' actor_type/actor_id/reason attribution columns.
type Actor struct {
ActorType string
ActorID uuid.NullUUID
Reason string
}
// ConferParams mirrors core.confer's signature. Exactly one of SubscriptionID,
// PurchaseID, or GrantID must be set (the function enforces it). ActivatedAt
// defaults to now when zero (the function forbids future activation).
type ConferParams struct {
PoolID string
ProductID string
SubscriptionID uuid.NullUUID
PurchaseID uuid.NullUUID
GrantID uuid.NullUUID
Quantity int32
ActivatedAt time.Time
EndedAt sql.NullTime
ActorType string
ActorID uuid.NullUUID
Reason sql.NullString
}
// Confer records the pool's position for (product, source) and returns the
// resulting provision id and the outcome ("created" or "noop").
func (q *Queries) Confer(ctx context.Context, arg ConferParams) (provisionID string, outcome string, err error) {
const query = `SELECT provision_id, outcome FROM core.confer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`
// Zero means "now": pass NULL and let the database supply it — a
// Go-sampled wall clock is always ahead of the frozen
// transaction_timestamp(), which core.confer's future guard rejects.
activatedAt := sql.NullTime{Time: arg.ActivatedAt, Valid: !arg.ActivatedAt.IsZero()}
actorType := arg.ActorType
if actorType == "" {
actorType = "system"
}
row := q.db.QueryRowContext(ctx, query,
arg.PoolID, arg.ProductID, arg.SubscriptionID, arg.PurchaseID, arg.GrantID,
arg.Quantity, activatedAt, arg.EndedAt, actorType, arg.ActorID, arg.Reason)
err = mapConferralError(row.Scan(&provisionID, &outcome))
return provisionID, outcome, err
}
// EndConferralParams mirrors core.end_conferral. Exactly one of SubscriptionID,
// PurchaseID, GrantID, or ProvisionID must be set. A source arc that resolves
// to nothing live is a success returning the empty set; an already-ended
// explicit provision id raises ErrConferralAlreadyEnded.
type EndConferralParams struct {
SubscriptionID uuid.NullUUID
PurchaseID uuid.NullUUID
GrantID uuid.NullUUID
ProvisionID uuid.NullUUID
ActorType string
ActorID uuid.NullUUID
Reason sql.NullString
EffectiveAt time.Time
}
// EndConferral ends the provision(s) matched by the selector and returns the
// ended provision ids.
func (q *Queries) EndConferral(ctx context.Context, arg EndConferralParams) ([]string, error) {
const query = `SELECT core.end_conferral($1, $2, $3, $4, $5, $6, $7, $8)`
effectiveAt := sql.NullTime{Time: arg.EffectiveAt, Valid: !arg.EffectiveAt.IsZero()}
actorType := arg.ActorType
if actorType == "" {
actorType = "system"
}
rows, err := q.db.QueryContext(ctx, query,
arg.SubscriptionID, arg.PurchaseID, arg.GrantID, arg.ProvisionID,
actorType, arg.ActorID, arg.Reason, effectiveAt)
if err != nil {
return nil, mapConferralError(err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, mapConferralError(err)
}
ids = append(ids, id)
}
return ids, mapConferralError(rows.Err())
}
// SyncSourceStatusParams mirrors core.sync_source_status. Status must be
// "suspended" or "active"; ending is end_conferral's job alone.
type SyncSourceStatusParams struct {
Status string
SubscriptionID uuid.NullUUID
PurchaseID uuid.NullUUID
GrantID uuid.NullUUID
EffectiveAt time.Time
}
// SyncSourceStatus suspends or resumes the source's live provisions (never
// writes 'ended', never resurrects) and returns the touched provision ids.
func (q *Queries) SyncSourceStatus(ctx context.Context, arg SyncSourceStatusParams) ([]string, error) {
const query = `SELECT core.sync_source_status($1, $2, $3, $4, $5)`
effectiveAt := sql.NullTime{Time: arg.EffectiveAt, Valid: !arg.EffectiveAt.IsZero()}
rows, err := q.db.QueryContext(ctx, query,
arg.Status, arg.SubscriptionID, arg.PurchaseID, arg.GrantID, effectiveAt)
if err != nil {
return nil, mapConferralError(err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, mapConferralError(err)
}
ids = append(ids, id)
}
return ids, mapConferralError(rows.Err())
}
// AlignConferralShapeRow is one rung reconciled by an alignment call.
type AlignConferralShapeRow struct {
PlanLadderID string
Action string // "attached" | "ended"
}
// AlignConferralShapeParams mirrors core.align_conferral_shape.
type AlignConferralShapeParams struct {
ProvisionID string
ActorType string
ActorID uuid.NullUUID
Reason sql.NullString
EffectiveAt time.Time
}
// AlignConferralShape attaches/ends rungs so an existing provision matches its
// product's current shape, returning the rungs it touched. Raises
// ErrConferralShapeCollision if a required rung is held by another provision.
func (q *Queries) AlignConferralShape(ctx context.Context, arg AlignConferralShapeParams) ([]AlignConferralShapeRow, error) {
const query = `SELECT plan_ladder_id, action FROM core.align_conferral_shape($1, $2, $3, $4, $5)`
effectiveAt := sql.NullTime{Time: arg.EffectiveAt, Valid: !arg.EffectiveAt.IsZero()}
actorType := arg.ActorType
if actorType == "" {
actorType = "system"
}
rows, err := q.db.QueryContext(ctx, query,
arg.ProvisionID, actorType, arg.ActorID, arg.Reason, effectiveAt)
if err != nil {
return nil, mapConferralError(err)
}
defer rows.Close()
out := []AlignConferralShapeRow{}
for rows.Next() {
var r AlignConferralShapeRow
if err := rows.Scan(&r.PlanLadderID, &r.Action); err != nil {
return nil, mapConferralError(err)
}
out = append(out, r)
}
return out, mapConferralError(rows.Err())
}
// UpdateConferralBoundsParams mirrors core.update_conferral_bounds. Grant-
// sourced provisions are rejected (bounds change via extend-as-replace).
type UpdateConferralBoundsParams struct {
ProvisionID string
Quantity sql.NullInt32
EndedAt sql.NullTime
SetEndedAt bool
ActorType string
ActorID uuid.NullUUID
Reason sql.NullString
}
// UpdateConferralBounds changes quantity/ended_at on a commercial provision
// without writing a position transition, returning the provision id.
func (q *Queries) UpdateConferralBounds(ctx context.Context, arg UpdateConferralBoundsParams) (string, error) {
const query = `SELECT core.update_conferral_bounds($1, $2, $3, $4, $5, $6, $7)`
actorType := arg.ActorType
if actorType == "" {
actorType = "system"
}
row := q.db.QueryRowContext(ctx, query,
arg.ProvisionID, arg.Quantity, arg.EndedAt, arg.SetEndedAt,
actorType, arg.ActorID, arg.Reason)
var id string
err := mapConferralError(row.Scan(&id))
return id, err
}
// ConferGrantInput records a grant decree and immediately confers it. Every
// grant-sourced caller (operator issuance/extend, auto-provisioning,
// reapply-defaults, demoseed) composes decree -> confer -> materialize through
// ConferGrantTx.
type ConferGrantInput struct {
ProductID string
OrgID string
GrantedByPersonID string // "" => system-authored; grant_reason must be "default"
GrantReason string
Description string
Quantity int32
ValidFrom sql.NullTime
ValidUntil sql.NullTime
ExtendsGrantID uuid.NullUUID
ActorType string // conferral actor: "operator", "system", "webhook"
ActorID uuid.NullUUID
TransitionReason string // free-text reason recorded on the position transition
}
// ConferGrantResult carries the recorded decree and the conferral outcome.
type ConferGrantResult struct {
Grant Grant
PoolID string
ProvisionID string
Outcome string // "created" | "noop"
}
// ConferGrantTx records the grant, confers it on the org's default pool, and
// materializes entitlements within the caller's transaction. A "noop" outcome
// still leaves the decree recorded (the grant is the ledger entry).
func ConferGrantTx(ctx context.Context, tx *sql.Tx, in ConferGrantInput) (*ConferGrantResult, error) {
q := New(tx)
qty := in.Quantity
if qty < 1 {
qty = 1
}
validFrom := time.Now()
if in.ValidFrom.Valid {
validFrom = in.ValidFrom.Time
}
var grantedBy uuid.NullUUID
if in.GrantedByPersonID != "" {
grantedBy = uuid.NullUUID{UUID: uuid.MustParse(in.GrantedByPersonID), Valid: true}
}
grant, err := q.CreateGrant(ctx, CreateGrantParams{
ProductID: in.ProductID,
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(in.OrgID), Valid: true},
GrantedByPersonID: grantedBy,
GrantReason: in.GrantReason,
Description: sql.NullString{String: in.Description, Valid: in.Description != ""},
Quantity: qty,
ValidFrom: validFrom,
ValidUntil: in.ValidUntil,
ExtendsGrantID: in.ExtendsGrantID,
})
if err != nil {
return nil, fmt.Errorf("create grant: %w", err)
}
pool, err := q.GetDefaultPoolByOrgID(ctx, in.OrgID)
if err != nil {
return nil, fmt.Errorf("get default pool for org %s: %w", in.OrgID, err)
}
provisionID, outcome, err := q.Confer(ctx, ConferParams{
PoolID: pool.PoolID,
ProductID: in.ProductID,
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
Quantity: qty,
ActorType: in.ActorType,
ActorID: in.ActorID,
Reason: sql.NullString{String: in.TransitionReason, Valid: in.TransitionReason != ""},
})
if err != nil {
return nil, fmt.Errorf("confer grant: %w", err)
}
if err := MaterializePoolEntitlements(ctx, q, pool.PoolID); err != nil {
return nil, fmt.Errorf("materialize entitlements: %w", err)
}
return &ConferGrantResult{Grant: grant, PoolID: pool.PoolID, ProvisionID: provisionID, Outcome: outcome}, nil
}
// ConferGrant runs ConferGrantTx in its own transaction.
func ConferGrant(ctx context.Context, db *sql.DB, in ConferGrantInput) (*ConferGrantResult, error) {
tx, err := BeginMaterializing(ctx, db, nil)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
result, err := ConferGrantTx(ctx, tx, in)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit transaction: %w", err)
}
return result, nil
}