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.
351 lines
12 KiB
Go
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
|
|
}
|