Files
member-console/internal/entitlements/conferral.go
T
cgalo5758 1a19ebe971 Implement uniform conferral semantics
Replace product-kind branching and direct position writes with enclosed
database functions driven by structural product shape.

Migrate grant and provision data, unify operator issuance, update
subscription and expiry flows, and add migration and integration proofs.
2026-07-11 13:05:53 -05:00

348 lines
12 KiB
Go

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 := db.BeginTx(ctx, 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
}