config

package
v0.0.0-...-ae331a0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: OSL-3.0 Imports: 18 Imported by: 0

README

Configuration Database for CUDly

This package provides a DynamoDB-backed configuration database with caching for CUDly application settings.

Overview

The configuration database provides:

  • Type-safe configuration storage with automatic type detection
  • In-memory caching with configurable TTL (default 5 minutes)
  • Default settings for all CUDly configuration categories
  • Thread-safe operations using read-write locks
  • Comprehensive test coverage with mocked DynamoDB

Files

configdb.go

Main implementation of the configuration database client.

Key Types:

  • ConfigDBClient - Main client with caching support
  • ConfigSetting - Represents a single configuration key-value pair
  • cachedSetting - Internal type wrapping settings with cache timestamps

Key Methods:

// Create new client
func NewConfigDBClient(dynamodbClient DynamoDBClient, tableName string) *ConfigDBClient

// Basic operations
func (c *ConfigDBClient) Get(ctx context.Context, key string) (*ConfigSetting, error)
func (c *ConfigDBClient) Set(ctx context.Context, key string, value interface{}) error
func (c *ConfigDBClient) Delete(ctx context.Context, key string) error
func (c *ConfigDBClient) GetAll(ctx context.Context) ([]ConfigSetting, error)
func (c *ConfigDBClient) GetByCategory(ctx context.Context, category string) ([]ConfigSetting, error)

// Type-safe getters with defaults
func (c *ConfigDBClient) GetInt(ctx context.Context, key string, defaultValue int) (int, error)
func (c *ConfigDBClient) GetFloat(ctx context.Context, key string, defaultValue float64) (float64, error)
func (c *ConfigDBClient) GetBool(ctx context.Context, key string, defaultValue bool) (bool, error)
func (c *ConfigDBClient) GetString(ctx context.Context, key string, defaultValue string) (string, error)

// Cache management
func (c *ConfigDBClient) SetCacheTTL(ttl time.Duration)
func (c *ConfigDBClient) InvalidateCache()
defaults.go

Comprehensive default settings for all CUDly configuration categories.

Configuration Categories:

  1. purchase_defaults - Default purchase settings (term, payment option, coverage, ramp schedule)
  2. notification - Email notification settings
  3. providers - Cloud provider enablement (AWS, Azure, GCP)
  4. security - Security settings (session duration, lockout, password requirements)
  5. scheduling - Automated collection and purchase scheduling
  6. aws - AWS-specific settings (utilization thresholds, Savings Plans options)
  7. thresholds - Cost and savings thresholds
  8. retention - Data retention periods
  9. api - API rate limiting and timeout settings

Helper Functions:

func GetDefaultValue(key string) interface{}
func GetDefaultSetting(key string) *ConfigSetting
func GetDefaultsByCategory(category string) []ConfigSetting
func GetAllCategories() []string
configdb_test.go & defaults_test.go

Comprehensive test suites with 100% coverage of all functionality.

DynamoDB Schema

Table Structure:

  • PK (Partition Key): "CONFIG" (constant for all config items)
  • SK (Sort Key): Configuration key (e.g., "purchase_defaults.term")
  • Value: The configuration value (supports multiple types)
  • Type: Type indicator ("int", "float", "bool", "string", "json")
  • Category: Logical grouping (e.g., "purchase_defaults", "notification")
  • Description: Human-readable description
  • UpdatedAt: ISO 8601 timestamp of last update

Usage Examples

Basic Usage
// Create client
client := config.NewConfigDBClient(dynamodbClient, "cudly-config-table")

// Get a setting with type safety
term, err := client.GetInt(ctx, "purchase_defaults.term", 3)
coverage, err := client.GetFloat(ctx, "purchase_defaults.coverage", 80.0)
emailEnabled, err := client.GetBool(ctx, "notification.email_enabled", true)

// Set a setting
err := client.Set(ctx, "purchase_defaults.term", 1)

// Get all settings in a category
notifications, err := client.GetByCategory(ctx, "notification")

// Get all settings as a map
allSettings, err := client.GetAsMap(ctx)
With Caching
// Create client with custom cache TTL
client := config.NewConfigDBClient(dynamodbClient, "cudly-config-table")
client.SetCacheTTL(10 * time.Minute)

// First call hits DynamoDB
setting1, _ := client.Get(ctx, "purchase_defaults.term")

// Second call uses cache (no DynamoDB call)
setting2, _ := client.Get(ctx, "purchase_defaults.term")

// Invalidate cache when needed
client.InvalidateCache()
Loading Defaults
// Get default value
defaultTerm := config.GetDefaultValue("purchase_defaults.term") // Returns: 3

// Get full default setting
setting := config.GetDefaultSetting("purchase_defaults.term")
// Returns: &ConfigSetting{
//   Key: "purchase_defaults.term",
//   Value: 3,
//   Type: "int",
//   Category: "purchase_defaults",
//   Description: "Default commitment term in years (1 or 3)",
// }

// Initialize database with defaults
for _, setting := range config.DefaultSettings {
    client.SaveSetting(ctx, &setting)
}

// Get all defaults for a category
purchaseDefaults := config.GetDefaultsByCategory("purchase_defaults")

Default Settings Reference

Purchase Defaults
  • purchase_defaults.term: 3 (int) - Commitment term in years
  • purchase_defaults.payment_option: "no-upfront" (string) - Payment option
  • purchase_defaults.coverage: 80.0 (float) - Coverage percentage
  • purchase_defaults.ramp_schedule: "immediate" (string) - Ramp schedule
Notification Settings
  • notification.days_before: 3 (int) - Days before purchase to notify
  • notification.email_enabled: true (bool) - Enable email notifications
  • notification.approval_required: true (bool) - Require approval
  • notification.email_from: "noreply@cudly.io" (string) - Sender email
Provider Settings
  • providers.aws_enabled: true (bool)
  • providers.azure_enabled: false (bool)
  • providers.gcp_enabled: false (bool)
Security Settings
  • security.session_duration_hours: 24 (int)
  • security.lockout_attempts: 5 (int)
  • security.lockout_duration_minutes: 15 (int)
  • security.password_min_length: 12 (int)
  • security.password_require_special: true (bool)
  • security.password_require_number: true (bool)
  • security.password_require_uppercase: true (bool)
Scheduling Settings
  • scheduling.auto_collect: false (bool)
  • scheduling.collect_schedule: "rate(1 day)" (string)
  • scheduling.auto_purchase: false (bool)
  • scheduling.purchase_schedule: "rate(1 day)" (string)
AWS-Specific Settings
  • aws.rds.min_utilization_percent: 50.0 (float)
  • aws.elasticache.min_utilization_percent: 50.0 (float)
  • aws.opensearch.min_utilization_percent: 50.0 (float)
  • aws.ec2.include_convertible: true (bool)
  • aws.savings_plans.compute_enabled: true (bool)
  • aws.savings_plans.ec2_enabled: true (bool)
  • aws.savings_plans.sagemaker_enabled: true (bool)
Thresholds
  • thresholds.min_monthly_savings: 10.0 (float)
  • thresholds.min_savings_percentage: 5.0 (float)
  • thresholds.max_upfront_cost: 0.0 (float) - 0 = no limit
Data Retention
  • retention.purchase_history_days: 1095 (int) - 3 years
  • retention.execution_history_days: 90 (int)
  • retention.recommendation_cache_hours: 24 (int)
API Settings
  • api.rate_limit_requests_per_minute: 100 (int)
  • api.rate_limit_enabled: true (bool)
  • api.timeout_seconds: 30 (int)

Performance Characteristics

  • Cache Hit: O(1) - In-memory map lookup
  • Cache Miss: O(1) - Single DynamoDB GetItem call
  • GetAll: O(n) - DynamoDB Query with single partition key
  • GetByCategory: O(n) - In-memory filtering after GetAll
  • Thread Safety: Read-write locks minimize contention

Testing

Run tests:

go test ./internal/config/...

Run with coverage:

go test -cover ./internal/config/...

Test features:

  • Unit tests with mocked DynamoDB client
  • Cache behavior tests (hit, miss, expiration, invalidation)
  • Type conversion tests
  • Default settings validation
  • Concurrent access simulation

Documentation

Overview

Package config provides configuration management functionality.

Package config provides configuration management for CUDly.

Package config provides configuration management using PostgreSQL.

Index

Constants

View Source
const (
	// DefaultListLimit is the default number of items returned in list operations.
	DefaultListLimit = 100

	// MaxListLimit is the maximum number of items allowed in a single list request.
	MaxListLimit = 1000

	// DefaultExecutionTTLDays is how long execution records are kept.
	DefaultExecutionTTLDays = 30

	// DefaultMaxRecommendationsInEmail is the max recommendations shown in email notifications.
	DefaultMaxRecommendationsInEmail = 10

	// DefaultPasswordResetExpiry is how long password reset tokens are valid.
	DefaultPasswordResetExpiry = 1 * time.Hour
)

Default configuration values.

View Source
const (
	// MaxCoverage is the maximum allowed coverage percentage.
	MaxCoverage = 100

	// MinCoverage is the minimum allowed coverage percentage.
	MinCoverage = 0

	// MaxPlanNameLength is the maximum length for plan names.
	MaxPlanNameLength = 100

	// MaxNotificationDaysBefore is the maximum days before purchase to send notification.
	MaxNotificationDaysBefore = 30

	// MaxStepIntervalDays is the maximum interval between ramp steps.
	MaxStepIntervalDays = 365

	// MaxTotalSteps is the maximum number of ramp steps.
	MaxTotalSteps = 100

	// MaxServiceMinCount caps the per-service min-count recommendation
	// filter. Mirrors the CLI's MaxReasonableInstances ceiling so a typo
	// (e.g. a stray trailing zero) can't silently suppress every
	// recommendation. 0 disables the filter; values above this are rejected
	// at validation time.
	MaxServiceMinCount = 10000
)

Validation constants.

View Source
const (
	// DefaultCoveragePercent is the default coverage percentage for new configs.
	DefaultCoveragePercent = 80

	// DefaultNotifyDaysBefore is the default days before purchase to send notification.
	DefaultNotifyDaysBefore = 7
)

Default values for new configurations.

View Source
const (
	// RampImmediate means all at once.
	RampImmediate = "immediate"

	// RampWeekly25Pct means 25% per week for 4 weeks.
	RampWeekly25Pct = "weekly-25pct" // #nosec G101 -- schedule constant; gosec misidentifies "pct" suffix as a credential pattern

	// RampMonthly10Pct means 10% per month for 10 months.
	RampMonthly10Pct = "monthly-10pct"

	// Weekly step interval in days.
	WeeklyStepIntervalDays = 7

	// Monthly step interval in days.
	MonthlyStepIntervalDays = 30
)

Ramp schedule presets.

View Source
const (
	// HoursPerDay is the number of hours in a day.
	HoursPerDay = 24

	// MinHoursBetweenNotifications is the minimum hours between notification emails.
	MinHoursBetweenNotifications = 24
)

Time constants.

View Source
const (
	// TokenByteLength is the length of generated tokens in bytes.
	TokenByteLength = 32

	// MFATimeStep is the TOTP time step in seconds.
	MFATimeStep = 30

	// MFADigits is the number of digits in MFA codes.
	MFADigits = 6
)

Token constants.

View Source
const (
	StatusCompleted          = "completed"
	StatusPartiallyCompleted = "partially_completed"
)

StatusCompleted / StatusPartiallyCompleted are the two terminal statuses that mean commitment was actually bought. A partially-completed row bought some of its recommendations and failed others (issue #642); it is a real purchase, which is why re-approving one would double-buy.

View Source
const (
	ListingStateActive    = "active"
	ListingStatePending   = "pending"
	ListingStateCancelled = "cancelled" //nolint:misspell // AWS ListingStatus enum literal, not prose
	ListingStateClosed    = "closed"
)

AWS EC2 ReservedInstancesListing status values, mirroring the ec2types.ListingStatus enum. Stored verbatim in purchase_history.listing_state so the strings must match AWS exactly. ListingStatePending is additionally written by the marketplace-list handler as a transient claim that guards against concurrent listing creation (issue #292); the other three come straight from AWS.

View Source
const ApprovalTokenTTL = 7 * 24 * time.Hour

ApprovalTokenTTL is the lifetime of a purchase approval token (issue #397). Tokens older than this are rejected by ApproveExecution and loadCancelableExecution. 7 days gives approvers a full business week to act without the window being infinite. Mirror the RI exchange model which uses a 6-hour TTL; purchase approvals are higher-stakes so a longer window is appropriate but must still be bounded.

View Source
const AzureRevocationWindowDays = 7

AzureRevocationWindowDays is the length of the Azure reservation free-cancel window: a reservation can be returned for a full refund within this many days of purchase (issue #290). It is the single source of truth for the window, referenced both at purchase-write time (to stamp PurchaseHistoryRecord.RevocationWindowClosesAt) and by the revoke endpoint's window check, so the two never drift.

View Source
const DefaultGracePeriodDays = 7

DefaultGracePeriodDays is the fallback window used when a provider has no entry in GlobalConfig.GracePeriodDays. A week gives cloud providers enough time to reflect a fresh commitment in their utilization metrics before we'd re-propose the same capacity.

View Source
const DefaultLadderBaselinePercentile = ladder.DefaultBaselinePercentile

DefaultLadderBaselinePercentile is the default usage percentile used to anchor the base commitment layer.

View Source
const DefaultLadderBufferFraction = ladder.DefaultBufferFraction

DefaultLadderBufferFraction is the default fraction of the base allocation reserved in short-term / convertible buffer commitments.

View Source
const DefaultLadderBufferUtilThreshold = ladder.DefaultBufferUtilizationThresholdPct

DefaultLadderBufferUtilThreshold is the default buffer-layer utilization % below which the engine emits a reshape recommendation.

View Source
const DefaultLadderLookbackDays = ladder.DefaultLookbackDays

DefaultLadderLookbackDays is the default historical window (days) used to compute the usage baseline.

View Source
const DefaultLadderMaxActionsPerRun = 10

DefaultLadderMaxActionsPerRun is the default cap on the number of PlannedActions the engine may execute per run.

View Source
const DefaultLadderTargetCoverage = ladder.DefaultTargetCoveragePct

DefaultLadderTargetCoverage is the default commitment coverage target (%). Aliases ladder.DefaultTargetCoveragePct so only one source of truth exists.

View Source
const DefaultPurchaseDelayHours = 48

DefaultPurchaseDelayHours is the default Gmail-style pre-fire delay. 48 hours gives most users a working-day window to spot and cancel an approval they didn't intend.

View Source
const DefaultRecommendationsCacheStaleHours = 24

DefaultRecommendationsCacheStaleHours is the default age (hours) after which the recommendations cache triggers a background refresh.

View Source
const DefaultRecommendationsLookbackDays = 7

DefaultRecommendationsLookbackDays is the default AWS Cost Explorer lookback window when no explicit value is configured.

View Source
const LegacyStatusCanceled = "cancel" + "led"

LegacyStatusCanceled is the British-spelling status value old code writes during the expand-contract rename (migration 000089). It is constructed by concatenation rather than a single literal so the US-locale misspell linter does not flag it -- this lets the dual-spelling read paths reference the legacy value without a //nolint:misspell directive. The CONTRACT migration (#1278) normalizes all rows to StatusCanceled once old code is gone, after which every reference to this constant can be deleted.

View Source
const MaxGracePeriodDays = 90

MaxGracePeriodDays is the ceiling enforced at read time as a safety net. The UI clamps input to [0, 30]; the DB isn't constrained, so a rogue write through psql shouldn't be able to suppress recs for years.

View Source
const MaxLadderActionsPerRun = 50

MaxLadderActionsPerRun is the ceiling enforced at validation time. A value above this is almost certainly a misconfiguration and should fail loud rather than fan out unbounded actions.

View Source
const MaxPurchaseDelayHours = 168

MaxPurchaseDelayHours is the ceiling for PurchaseDelayHours. One week is long enough for any reasonable review cycle; longer delays make the UX confusing and the scheduler overhead non-trivial.

View Source
const MaxRecommendationsCacheStaleHours = 8760

MaxRecommendationsCacheStaleHours is the maximum configurable stale threshold: one year. Values above this are rejected at validation time.

View Source
const RevocationWindow = 24 * time.Hour

RevocationWindow is the time window after a purchase completes during which the buyer may request revocation (issue #291). 24 hours matches the AWS RI/SP support-case window advertised in the post-execution email. It is the single source of truth for both the fresh revocation token's expiry (minted in purchase.mintRevocationToken) and the enforcement check in api.validateRevokeToken: the two MUST stay equal or a token could expire before the window closes and silently block a valid revoke.

View Source
const StatusCanceled = "canceled"

StatusCanceled is the canonical US-spelling status value new code writes.

View Source
const StatusExpired = "expired"

StatusExpired is written when an approval lapsed before anyone acted on it.

View Source
const StatusFailed = "failed"

StatusFailed is the terminal status written when an execution's purchase attempt errors out, and by the stuck-purchase reaper (internal/purchase/ reaper.go) for rows left in approved/running past its threshold.

Variables

View Source
var (
	RampStepSucceededStatuses = []string{StatusCompleted, StatusPartiallyCompleted}
	RampStepSettledStatuses   = []string{StatusCompleted, StatusPartiallyCompleted, StatusCanceled, LegacyStatusCanceled}
	RampStepStuckStatuses     = []string{StatusFailed, StatusExpired}
)

The three ramp-step status classes below drive the advance gate in CompletePlanStep and the stuck-ramp report GetStuckRampSteps (issue #1861). They are separate exported lists rather than one, because the gate asks two different questions of the same rows and the health report asks a third:

  • RampStepSucceededStatuses answers "did this fan-out unit buy?". A step no unit bought must not advance the ramp at all.
  • RampStepSettledStatuses answers "is this fan-out unit done holding the step open?". It is deliberately an ALLOWLIST: a status added to the schema later holds the step open until someone classifies it, which is the fail-closed direction on a money path.
  • RampStepStuckStatuses answers "is this unit stuck rather than in flight?". Only these produce the plan-health ramp_blocked factor, so a retry still working its way through pending/running reads as recovery rather than as a frozen ramp.

Canceled counts as settled but neither succeeded nor stuck: to cancel a step's row is an operator deciding that unit will not buy, and that decision is what releases a ramp an unrecoverable account would otherwise freeze. Both spellings are listed for the duration of the expand-contract rename (migration 000089, contract in #1278), same as HealthScoredExecutionStatuses.

RampStepSucceededStatuses is the same predicate migration 000098 spells out inline as status IN ('completed','partially_completed') when it decides which rows a sibling has already bought for. The migration is frozen SQL and cannot import this, so the two are kept identical by hand: changing one without the other would make the backfill and the advance gate disagree about what "bought" means.

View Source
var DefaultSettings = []ConfigSetting{

	{
		Key:         "purchase_defaults.term",
		Value:       3,
		Type:        "int",
		Category:    "purchase_defaults",
		Description: "Default commitment term in years (1 or 3)",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "purchase_defaults.payment_option",
		Value:       "no-upfront",
		Type:        "string",
		Category:    "purchase_defaults",
		Description: "Default payment option: no-upfront, partial-upfront, all-upfront",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "purchase_defaults.coverage",
		Value:       80.0,
		Type:        "float",
		Category:    "purchase_defaults",
		Description: "Default coverage percentage (0-100)",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "purchase_defaults.ramp_schedule",
		Value:       "immediate",
		Type:        "string",
		Category:    "purchase_defaults",
		Description: "Default ramp schedule: immediate, weekly-25pct, monthly-10pct",
		UpdatedAt:   time.Time{},
	},

	{
		Key:         "notification.days_before",
		Value:       3,
		Type:        "int",
		Category:    "notification",
		Description: "Days before purchase to send notification",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "notification.email_enabled",
		Value:       true,
		Type:        "bool",
		Category:    "notification",
		Description: "Enable email notifications for purchases",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "notification.approval_required",
		Value:       true,
		Type:        "bool",
		Category:    "notification",
		Description: "Require approval before executing purchases",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "notification.email_from",
		Value:       "noreply@cudly.io",
		Type:        "string",
		Category:    "notification",
		Description: "Email sender address for notifications",
		UpdatedAt:   time.Time{},
	},

	{
		Key:         "providers.aws_enabled",
		Value:       true,
		Type:        "bool",
		Category:    "providers",
		Description: "Enable AWS provider for recommendations and purchases",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "providers.azure_enabled",
		Value:       false,
		Type:        "bool",
		Category:    "providers",
		Description: "Enable Azure provider for recommendations and purchases",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "providers.gcp_enabled",
		Value:       false,
		Type:        "bool",
		Category:    "providers",
		Description: "Enable GCP provider for recommendations and purchases",
		UpdatedAt:   time.Time{},
	},

	{
		Key:         "security.session_duration_hours",
		Value:       24,
		Type:        "int",
		Category:    "security",
		Description: "Session duration in hours before re-authentication required",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "security.lockout_attempts",
		Value:       5,
		Type:        "int",
		Category:    "security",
		Description: "Failed login attempts before account lockout",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "security.lockout_duration_minutes",
		Value:       15,
		Type:        "int",
		Category:    "security",
		Description: "Account lockout duration in minutes",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "security.password_min_length",
		Value:       12,
		Type:        "int",
		Category:    "security",
		Description: "Minimum password length requirement",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "security.password_require_special",
		Value:       true,
		Type:        "bool",
		Category:    "security",
		Description: "Require special characters in passwords",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "security.password_require_number",
		Value:       true,
		Type:        "bool",
		Category:    "security",
		Description: "Require numbers in passwords",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "security.password_require_uppercase",
		Value:       true,
		Type:        "bool",
		Category:    "security",
		Description: "Require uppercase letters in passwords",
		UpdatedAt:   time.Time{},
	},

	{
		Key:         "scheduling.auto_collect",
		Value:       false,
		Type:        "bool",
		Category:    "scheduling",
		Description: "Automatically collect recommendations on schedule",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "scheduling.collect_schedule",
		Value:       "rate(1 day)",
		Type:        "string",
		Category:    "scheduling",
		Description: "Schedule for automatic recommendation collection (EventBridge format)",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "scheduling.auto_purchase",
		Value:       false,
		Type:        "bool",
		Category:    "scheduling",
		Description: "Automatically execute approved purchase plans",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "scheduling.purchase_schedule",
		Value:       "rate(1 day)",
		Type:        "string",
		Category:    "scheduling",
		Description: "Schedule for checking and executing purchase plans (EventBridge format)",
		UpdatedAt:   time.Time{},
	},

	{
		Key:         "aws.rds.min_utilization_percent",
		Value:       50.0,
		Type:        "float",
		Category:    "aws",
		Description: "Minimum RDS instance utilization for RI recommendations",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "aws.elasticache.min_utilization_percent",
		Value:       50.0,
		Type:        "float",
		Category:    "aws",
		Description: "Minimum ElastiCache node utilization for RI recommendations",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "aws.opensearch.min_utilization_percent",
		Value:       50.0,
		Type:        "float",
		Category:    "aws",
		Description: "Minimum OpenSearch instance utilization for RI recommendations",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "aws.ec2.include_convertible",
		Value:       true,
		Type:        "bool",
		Category:    "aws",
		Description: "Include convertible EC2 Reserved Instances in recommendations",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "aws.savings_plans.compute_enabled",
		Value:       true,
		Type:        "bool",
		Category:    "aws",
		Description: "Include Compute Savings Plans in recommendations",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "aws.savings_plans.ec2_enabled",
		Value:       true,
		Type:        "bool",
		Category:    "aws",
		Description: "Include EC2 Instance Savings Plans in recommendations",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "aws.savings_plans.sagemaker_enabled",
		Value:       true,
		Type:        "bool",
		Category:    "aws",
		Description: "Include SageMaker Savings Plans in recommendations",
		UpdatedAt:   time.Time{},
	},

	{
		Key:         "thresholds.min_monthly_savings",
		Value:       10.0,
		Type:        "float",
		Category:    "thresholds",
		Description: "Minimum monthly savings ($) to include recommendation",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "thresholds.min_savings_percentage",
		Value:       5.0,
		Type:        "float",
		Category:    "thresholds",
		Description: "Minimum savings percentage to include recommendation",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "thresholds.max_upfront_cost",
		Value:       0.0,
		Type:        "float",
		Category:    "thresholds",
		Description: "Maximum upfront cost ($) per purchase (0 = no limit)",
		UpdatedAt:   time.Time{},
	},

	{
		Key:         "retention.purchase_history_days",
		Value:       1095,
		Type:        "int",
		Category:    "retention",
		Description: "Days to retain purchase history (3 years default)",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "retention.execution_history_days",
		Value:       90,
		Type:        "int",
		Category:    "retention",
		Description: "Days to retain execution history records",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "retention.recommendation_cache_hours",
		Value:       24,
		Type:        "int",
		Category:    "retention",
		Description: "Hours to cache recommendation data",
		UpdatedAt:   time.Time{},
	},

	{
		Key:         "ri_exchange.auto_exchange_enabled",
		Value:       false,
		Type:        "bool",
		Category:    "ri_exchange",
		Description: "Master toggle for automated RI exchange",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "ri_exchange.mode",
		Value:       "manual",
		Type:        "string",
		Category:    "ri_exchange",
		Description: "Exchange mode: manual (email approval) or auto (fully automated)",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "ri_exchange.utilization_threshold",
		Value:       95.0,
		Type:        "float",
		Category:    "ri_exchange",
		Description: "Utilization percentage below which an RI triggers exchange consideration",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "ri_exchange.max_payment_per_exchange_usd",
		Value:       0.0,
		Type:        "float",
		Category:    "ri_exchange",
		Description: "Maximum payment per single exchange in USD (0 = refuse any payment)",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "ri_exchange.max_payment_daily_usd",
		Value:       0.0,
		Type:        "float",
		Category:    "ri_exchange",
		Description: "Maximum total daily exchange spend in USD (0 = refuse any payment)",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "ri_exchange.lookback_days",
		Value:       30,
		Type:        "int",
		Category:    "ri_exchange",
		Description: "Days of utilization data to consider for exchange recommendations",
		UpdatedAt:   time.Time{},
	},

	{
		Key:         "api.rate_limit_requests_per_minute",
		Value:       100,
		Type:        "int",
		Category:    "api",
		Description: "Maximum API requests per minute per user",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "api.rate_limit_enabled",
		Value:       true,
		Type:        "bool",
		Category:    "api",
		Description: "Enable API rate limiting",
		UpdatedAt:   time.Time{},
	},
	{
		Key:         "api.timeout_seconds",
		Value:       30,
		Type:        "int",
		Category:    "api",
		Description: "Default API request timeout in seconds",
		UpdatedAt:   time.Time{},
	},
}

DefaultSettings defines the default configuration values for CUDly. UpdatedAt is the zero time.Time{} for every entry: these are static compile-time defaults and have never been "updated" by a user.

View Source
var ErrAuditLoss = errors.New("audit loss: execution persistence failed after purchase")

ErrAuditLoss is returned (wrapped) by executeAndFinalize when the purchase run itself completed but the subsequent SavePurchaseExecution call failed. The execution is already "running" (per the CAS in claimAndRedrive) but its final state was never persisted -- the row is stranded in "running" until the next recovery sweep. Callers that silence all drive errors (e.g. claimAndRedrive) must propagate this sentinel so the sweep surfaces the persistence failure rather than silently dropping the stranded row.

View Source
var ErrExecutionNotInExpectedStatus = errors.New("execution not in expected status")

ErrExecutionNotInExpectedStatus is returned by TransitionExecutionStatus when the target execution exists but its current status is not in the allowed `fromStatuses` set -- i.e. the atomic CAS rejected because some other writer transitioned the row first (e.g. the real executor finished between the reaper's SELECT and CAS). Callers can use errors.Is to distinguish this legitimate race-loss from a hard DB error.

View Source
var ErrNotFound = errors.New("not found")

ErrNotFound is returned when a requested config-store row does not exist.

View Source
var ErrRampStepAlreadyCounted = errors.New("ramp step already counted")

ErrRampStepAlreadyCounted is returned (wrapped) by CompletePlanStep when the plan has moved PAST the completing step. Unlike ErrRampStepCountedBySibling this is an anomaly worth an audit note on the execution row: the purchase bought commitment for a step the ramp counted earlier and will never count again, which is how a step_number stamped by pre-#1669 code (or during that deploy overlap) surfaces.

View Source
var ErrRampStepCountedBySibling = errors.New("ramp step already counted by a sibling execution")

ErrRampStepCountedBySibling is returned (wrapped) by CompletePlanStep when the plan is sitting exactly on the completing step: a sibling execution of the same step won the race to advance it, moments earlier. Routine on a multi-account step whose last two accounts finish together, and benign -- the step WAS counted, just not by this caller -- so it must not be stamped on the execution row. A note there would flip a cleanly-completed purchase into History's audit-gap rendering, which keys on a non-empty error.

View Source
var ErrRampStepIncomplete = errors.New("ramp step incomplete")

ErrRampStepIncomplete is returned (wrapped) by CompletePlanStep when the ramp step the completing execution belongs to has not been bought in full: another cloud account on that step is still outstanding, or nothing on the step bought at all. Counting it would report commitment the plan has not made (issue #1861). Transient by construction -- it clears when the outstanding account buys, when its row is canceled, or when the step is abandoned -- so callers must treat it as "not yet", not as a failure of the purchase that just completed.

The sentinel text stays generic because both cases wrap it and each supplies its own specifics; naming one of them here would mis-describe the other.

View Source
var ErrRegistrationConflict = errors.New("registration status conflict: already processed")

ErrRegistrationConflict is returned when a registration status transition fails because another request already changed the status (concurrent modification).

View Source
var HealthScoredExecutionStatuses = []string{
	StatusFailed,
	StatusCanceled,
	LegacyStatusCanceled,
}

HealthScoredExecutionStatuses are the execution statuses the plan health score counts (internal/api/plan_health.go). It lives here, in the package that owns both the status constants and the retention sweep, because two unrelated-looking pieces of code have to agree on it exactly:

  • CountExecutionsByPlanAndStatus is asked for these statuses and windows them on updated_at.
  • CleanupOldExecutions must not delete a row in one of these statuses while that window still covers it. Retaining on any other clock lets the sweep purge a row the score is still counting, which shows up as a plan's health score jumping overnight with no operator action.

Keeping one exported slice rather than a literal list on each side is what makes that agreement structural: adding a status to the score automatically extends the sweep's exclusion, so the two cannot drift.

Both spellings of canceled are present for the duration of the expand-contract rename (migration 000089, contract in #1278).

View Source
var PresetRampSchedules = map[string]RampSchedule{
	"immediate": {
		Type:           "immediate",
		PercentPerStep: 100,
		TotalSteps:     1,
	},
	"weekly-25pct": {
		Type:             "weekly",
		PercentPerStep:   25,
		StepIntervalDays: 7,
		TotalSteps:       4,
	},
	"monthly-10pct": {
		Type:             "monthly",
		PercentPerStep:   10,
		StepIntervalDays: 30,
		TotalSteps:       10,
	},
}

PresetRampSchedules provides common ramp-up configurations.

View Source
var ValidCollectionSchedules = []string{"", "hourly", "daily", "weekly"}

ValidCollectionSchedules lists all valid collection schedule values.

View Source
var ValidOfferingClasses = []string{"convertible", "standard"}

ValidOfferingClasses lists the accepted EC2 RI offering class values for GlobalConfig. The empty string is also accepted (maps to "convertible" at purchase time to preserve pre-694 behavior).

View Source
var ValidPaymentOptions = []string{"no-upfront", "partial-upfront", "all-upfront"}

ValidPaymentOptions lists the AWS-canonical payment options. Kept for backwards compatibility; prefer ValidPaymentOptionsByProvider for provider-aware validation.

View Source
var ValidPaymentOptionsByProvider = map[string][]string{
	"aws":   {"no-upfront", "partial-upfront", "all-upfront"},
	"azure": {"upfront", "monthly"},
	"gcp":   {"monthly"},
}

ValidPaymentOptionsByProvider maps each provider to the payment option tokens it accepts. Each provider's set is the canonical set verified against the provider's service-client switch statements:

  • aws: {no-upfront, partial-upfront, all-upfront}: the three RI/SP billing tiers exposed by AWS APIs.

  • azure: {upfront, monthly}: verified against the 7 Azure service-client switches in providers/azure/services/{compute,cache,cosmosdb,database, search,synapse,managedredis}/client.go. (The savingsplans client mirrors AWS's three-tier set, but Azure savings-plan recs are not currently emitted; GetRecommendations returns []. The canonical set follows the only path that emits today.)

  • gcp: {monthly}: GCP CUDs are billed monthly across the commitment term; there is no upfront billing tier. See providers/gcp/services/computeengine/client.go:buildCommitmentRequests which takes only a Plan (TWELVE_MONTH/THIRTY_SIX_MONTH) and never reads PaymentOption.

Cross-provider tokens are rejected loudly by the validator. Legacy AWS-style tokens emitted by older code paths are canonicalized via NormalizePaymentOption at the rec-emission boundary BEFORE reaching this validator (see internal/scheduler/scheduler.go:convertRecommendations).

View Source
var ValidProviders = []string{"aws", "azure", "gcp"}

ValidProviders lists all supported cloud providers.

View Source
var ValidRampScheduleTypes = []string{"immediate", "weekly", "monthly", "custom"}

ValidRampScheduleTypes lists all supported ramp schedule types.

View Source
var ValidRecommendationsLookbackDays = []int{7, 30, 60}

ValidRecommendationsLookbackDays lists the AWS Cost Explorer LookbackPeriodInDays enum values. Other values are rejected.

Functions

func AccountConfigKey

func AccountConfigKey(accountID, provider, service string) string

AccountConfigKey returns the map key used by ResolveAccountConfigsForRecs. Exposed so callers (scheduler filter, dashboard aggregator) can look up the resolved config for a given rec without re-implementing the format.

func DerivePlanProviders

func DerivePlanProviders(plan *PurchasePlan) []string

DerivePlanProviders extracts the distinct set of providers a plan targets by parsing the keys of plan.Services. Keys are expected to use the "provider/service" format produced by buildServiceConfig.

func GetAllCategories

func GetAllCategories() []string

GetAllCategories returns a list of all configuration categories.

func GetDefaultValue

func GetDefaultValue(key string) any

GetDefaultValue returns the default value for a given key.

func NormalizePaymentOption

func NormalizePaymentOption(provider, raw string) (string, bool)

NormalizePaymentOption maps a raw payment-option token onto the canonical token the given provider semantically models (see ValidPaymentOptionsByProvider). It exists so the recommendation-emission boundary (see internal/scheduler/scheduler.go:convertRecommendations) can defensively canonicalize any AWS-style token that a code path or a globally-default payment-option setting might stamp onto a non-AWS rec, before the rec is persisted and later validated against the provider-canonical set.

Returns (canonical, true) when raw is already canonical for the provider or has an unambiguous canonical mapping. Returns ("", false) for unknown providers and (raw, false) for tokens that have no canonical mapping on the given provider (e.g. an Azure/GCP-style "upfront" on AWS) — callers can use ok=false to surface the unmapped token at the next validator boundary. Per-provider mapping:

  • AWS : passthrough (AWS already speaks the three-tier set).
  • Azure: all-upfront → upfront, no-upfront → monthly, partial-upfront → monthly (no semantic equivalent — coerce to the no-upfront tier, CUDly's default billing schedule for Azure, rather than drop the rec; caller may log). Coercing to upfront here would silently bill an all-upfront schedule the caller never chose: the canonical token is persisted onto the execution and copied into common.Recommendation.PaymentOption (internal/purchase/execution.go), and the reservation-purchase billingPlan wiring being added in #1495/#1502 maps only upfront/monthly tokens and hard-errors on partial-upfront, so landing on monthly keeps the rec on the no-upfront schedule instead of an irreversible upfront charge — Azure does not allow changing a reservation's billing frequency after purchase.
  • GCP : all-upfront → monthly, no-upfront → monthly, partial-upfront → monthly, upfront → monthly (GCP CUDs are inherently monthly-billed — every non-monthly token collapses to the one billing plan GCP actually models). The collapse from "upfront" to "monthly" is what makes the existing providers/gcp/services/computeengine/client.go:804 stamp safe: the scheduler.convertRecommendations boundary coerces it once before persistence so the rec carries the canonical token downstream.

The partial-upfront coercion is deliberate on both Azure and GCP: dropping the rec would be a silent data loss for the user, while coercing to the closest billing model the provider offers preserves the rec without changing the total the caller committed to. Microsoft documents that choice as cost-neutral: "The total cost of up-front and monthly reservations is the same and you don't pay any extra fees when you choose to pay monthly" (https://learn.microsoft.com/en-us/azure/cost-management-billing/reservations/prepare-buy-reservation). Caveat from the same page: monthly payments are NOT offered for SUSE Linux reservations, Red Hat plans, Azure Red Hat OpenShift licenses, or pre-purchase plans, so for those products the coerced token can make Azure reject the purchase. That is the intended failure mode — a loud purchase- time rejection is preferable to silently committing the caller to an upfront charge they never chose and cannot undo.

The caller is expected to log a warning when raw != canonical so an operator notices the upstream input bug.

The frontend mirrors this mapping in frontend/src/commitmentOptions.ts:normalizePaymentValue (it decides which option the plan/purchase dropdowns pre-select); the two must stay in lockstep.

Empty raw passes through as ("", true) — callers that distinguish "unset" from "invalid" can check the returned bool only when raw is non-empty.

func PaymentCoercionChangesSchedule

func PaymentCoercionChangesSchedule(raw, canonical string) bool

PaymentCoercionChangesSchedule reports whether rewriting raw to canonical actually changes what the customer pays and when, as opposed to merely renaming the same schedule into the target provider's vocabulary.

Azure "all-upfront" -> "upfront" and Azure/GCP "no-upfront" -> "monthly" are renames: identical cash flow, different spelling. Azure "partial-upfront" -> "monthly" and GCP "upfront" -> "monthly" are real changes: the customer is billed on a schedule they did not ask for.

Only real changes are worth putting in front of a user (#1503). The fan-out purchase modal submits the AWS-style "all-upfront" for Azure buckets by construction (frontend/src/lib/purchase-compatibility.ts: paymentOptionsFor), so treating every rewrite as a change would fire a "billing schedule adjusted" warning on the ordinary Azure upfront purchase and train users to dismiss the one notice that matters.

func ResolveAccountConfigsForRecs

func ResolveAccountConfigsForRecs(
	ctx context.Context,
	store AccountConfigReader,
	recs []RecommendationRecord,
) (map[string]*ServiceConfig, error)

ResolveAccountConfigsForRecs walks the recs once, collects the unique (cloud_account_id, provider, service) triples, and resolves each via ResolveServiceConfig(provider, service, global, override). Returns a map keyed by AccountConfigKey -> resolved *ServiceConfig.

Triples are skipped (not present in the map) when:

  • rec.CloudAccountID is nil — no per-account override possible (e.g. AWS ambient-credentials path).
  • Neither a global ServiceConfig nor a per-account override exists for the (provider, service) pair — no configuration to apply, so callers treat the triple as "no filter applies".

When a per-account override exists but no global ServiceConfig does, the override is applied against a synthesized default baseline (Enabled: true) so the operator's intent is honored even when a global row has not been created yet.

Errors from either lookup are returned alongside the partial map so the caller decides whether to fail the whole operation or pass through. The scheduler / dashboard read paths choose pass-through (over-show vs. under-show), matching the precedent set by applySuppressions.

func RevocationWindowClosesAtFor

func RevocationWindowClosesAtFor(provider string, purchaseTime time.Time) *time.Time

RevocationWindowClosesAtFor returns the timestamp at which the in-app revoke button should stop being offered for a purchase of the given provider made at purchaseTime, or nil when the provider has no in-app free-cancel window.

Only Azure has a direct-API free-cancel window in Phase 1. AWS EC2 RIs have a 24h window but no direct cancel API (revocation goes through an AWS Support case, out of Phase-1 scope), and GCP commitments have no free-cancel window at all, so both return nil and the History UI hides the button.

func ValidatePaymentOptionEnv

func ValidatePaymentOptionEnv(val string) error

ValidatePaymentOptionEnv validates a payment-option value read from an environment variable (e.g. DEFAULT_PAYMENT_OPTION). Empty string is always valid ("use the purchase manager's built-in default"). Non-empty values must be in the union of all provider payment option sets. Called by the server startup boundary so misconfiguration is caught at boot time rather than silently propagated into purchases (issue #1026).

func ValidateRampScheduleEnv

func ValidateRampScheduleEnv(val string) error

ValidateRampScheduleEnv validates a ramp-schedule value read from an environment variable (e.g. DEFAULT_RAMP_SCHEDULE). Empty string is always valid ("use the purchase manager's built-in default"). Non-empty values must be in ValidRampScheduleTypes. Called by the server startup boundary so misconfiguration is caught at boot time rather than silently propagated into purchases (issue #1026).

Types

type AccountConfigReader

type AccountConfigReader interface {
	GetServiceConfig(ctx context.Context, provider, service string) (*ServiceConfig, error)
	GetAccountServiceOverride(ctx context.Context, accountID, provider, service string) (*AccountServiceOverride, error)
}

AccountConfigReader is the minimal store surface needed by ResolveAccountConfigsForRecs. Both PostgresStore (production) and the scheduler's MockConfigStore (tests) already satisfy it via the broader StoreInterface — we narrow here so the helper can be unit-tested with a tiny ad-hoc fake.

type AccountRegistration

type AccountRegistration struct {
	ID                   string     `json:"id"`
	ReferenceToken       string     `json:"reference_token"`
	Status               string     `json:"status"` // pending, approved, rejected
	Provider             string     `json:"provider"`
	ExternalID           string     `json:"external_id"`
	AccountName          string     `json:"account_name"`
	ContactEmail         string     `json:"contact_email"`
	Description          string     `json:"description,omitempty"`
	SourceProvider       string     `json:"source_provider,omitempty"`
	AWSRoleARN           string     `json:"aws_role_arn,omitempty"`
	AWSAuthMode          string     `json:"aws_auth_mode,omitempty"`
	AWSExternalID        string     `json:"aws_external_id,omitempty"`
	AzureSubscriptionID  string     `json:"azure_subscription_id,omitempty"`
	AzureTenantID        string     `json:"azure_tenant_id,omitempty"`
	AzureClientID        string     `json:"azure_client_id,omitempty"`
	AzureAuthMode        string     `json:"azure_auth_mode,omitempty"`
	GCPProjectID         string     `json:"gcp_project_id,omitempty"`
	GCPClientEmail       string     `json:"gcp_client_email,omitempty"`
	GCPAuthMode          string     `json:"gcp_auth_mode,omitempty"`
	GCPWIFAudience       string     `json:"gcp_wif_audience,omitempty"` // Full WIF provider resource; only set for federated path.
	RegCredentialType    string     `json:"reg_credential_type,omitempty"`
	RegCredentialPayload string     `json:"-"`                         // never returned in API responses (encrypted at rest)
	HasCredentials       bool       `json:"has_credentials,omitempty"` // derived: true when reg_credential_type is set
	RejectionReason      string     `json:"rejection_reason,omitempty"`
	CloudAccountID       *string    `json:"cloud_account_id,omitempty"`
	ReviewedBy           *string    `json:"reviewed_by,omitempty"`
	ReviewedAt           *time.Time `json:"reviewed_at,omitempty"`
	CreatedAt            time.Time  `json:"created_at"`
	UpdatedAt            time.Time  `json:"updated_at"`
}

AccountRegistration represents a self-service registration request from a target account owner. Submitted via POST /api/register during Terraform apply of the federation IaC, then approved or rejected by a CUDly admin.

type AccountRegistrationFilter

type AccountRegistrationFilter struct {
	Status   *string
	Provider *string
	Search   string
}

AccountRegistrationFilter for ListAccountRegistrations queries.

type AccountServiceOverride

type AccountServiceOverride struct {
	ID             string    `json:"id"`
	AccountID      string    `json:"account_id"`
	Provider       string    `json:"provider"`
	Service        string    `json:"service"`
	Enabled        *bool     `json:"enabled,omitempty"`
	Term           *int      `json:"term,omitempty"`
	Payment        *string   `json:"payment,omitempty"`
	Coverage       *float64  `json:"coverage,omitempty"`
	RampSchedule   *string   `json:"ramp_schedule,omitempty"`
	IncludeEngines []string  `json:"include_engines,omitempty"`
	ExcludeEngines []string  `json:"exclude_engines,omitempty"`
	IncludeRegions []string  `json:"include_regions,omitempty"`
	ExcludeRegions []string  `json:"exclude_regions,omitempty"`
	IncludeTypes   []string  `json:"include_types,omitempty"`
	ExcludeTypes   []string  `json:"exclude_types,omitempty"`
	CreatedAt      time.Time `json:"created_at"`
	UpdatedAt      time.Time `json:"updated_at"`
}

AccountServiceOverride is a sparse per-account override on top of the global ServiceConfig. Nil pointer fields inherit the global value.

type CloudAccount

type CloudAccount struct {
	ID           string `json:"id"`
	Name         string `json:"name"`
	Description  string `json:"description,omitempty"`
	ContactEmail string `json:"contact_email,omitempty"`
	Enabled      bool   `json:"enabled"`
	Provider     string `json:"provider"`
	ExternalID   string `json:"external_id"`

	// AWS-specific
	AWSAuthMode             string `json:"aws_auth_mode,omitempty"`
	AWSRoleARN              string `json:"aws_role_arn,omitempty"`
	AWSExternalID           string `json:"aws_external_id,omitempty"`
	AWSBastionID            string `json:"aws_bastion_id,omitempty"`
	AWSWebIdentityTokenFile string `json:"aws_web_identity_token_file,omitempty"`
	AWSIsOrgRoot            bool   `json:"aws_is_org_root,omitempty"`

	// Azure-specific
	AzureSubscriptionID string `json:"azure_subscription_id,omitempty"`
	AzureTenantID       string `json:"azure_tenant_id,omitempty"`
	AzureClientID       string `json:"azure_client_id,omitempty"`
	AzureAuthMode       string `json:"azure_auth_mode,omitempty"`

	// GCP-specific
	GCPProjectID   string `json:"gcp_project_id,omitempty"`
	GCPClientEmail string `json:"gcp_client_email,omitempty"`
	GCPAuthMode    string `json:"gcp_auth_mode,omitempty"`
	// GCPWIFAudience is the full Workload Identity Pool provider
	// resource used as the STS audience when exchanging a CUDly
	// KMS-signed JWT for a GCP access token. Only set for accounts
	// using the secret-free workload_identity_federation path.
	// Shape: //iam.googleapis.com/projects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>
	GCPWIFAudience string `json:"gcp_wif_audience,omitempty"`

	// Derived (not stored in DB)
	CredentialsConfigured bool   `json:"credentials_configured"`
	BastionAccountName    string `json:"bastion_account_name,omitempty"`
	IsSelf                bool   `json:"is_self,omitempty"`

	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
	CreatedBy string    `json:"created_by,omitempty"`
}

CloudAccount represents a single managed cloud account/subscription/project.

type CloudAccountFilter

type CloudAccountFilter struct {
	Provider  *string
	Enabled   *bool
	Search    string  // substring match on name or external_id
	BastionID *string // return accounts whose aws_bastion_id = *BastionID
}

CloudAccountFilter for ListCloudAccounts queries.

type ConfigSetting

type ConfigSetting struct {
	Key         string    `json:"key"`
	Value       any       `json:"value"`
	Type        string    `json:"type"` // int, float, bool, string, json
	Category    string    `json:"category"`
	Description string    `json:"description"`
	UpdatedAt   time.Time `json:"updated_at"`
}

ConfigSetting represents a configuration setting for the defaults system.

func GetDefaultSetting

func GetDefaultSetting(key string) *ConfigSetting

GetDefaultSetting returns the complete default setting for a given key.

func GetDefaultsByCategory

func GetDefaultsByCategory(category string) []ConfigSetting

GetDefaultsByCategory returns all default settings for a given category.

type ExecutionStatusCounts

type ExecutionStatusCounts map[string]int

ExecutionStatusCounts maps an execution status value to the number of executions in that status, for a single plan. Returned by CountExecutionsByPlanAndStatus; a status with no rows is simply absent from the map, so a plain lookup yields the correct zero.

type GlobalConfig

type GlobalConfig struct {
	EnabledProviders       []string `json:"enabled_providers" dynamodbav:"enabled_providers"`
	NotificationEmail      *string  `json:"notification_email,omitempty" dynamodbav:"notification_email,omitempty"`
	AutoCollect            bool     `json:"auto_collect"`
	CollectionSchedule     string   `json:"collection_schedule"`
	NotificationDaysBefore int      `json:"notification_days_before"`
	ApprovalRequired       bool     `json:"approval_required" dynamodbav:"approval_required"`
	DefaultTerm            int      `json:"default_term" dynamodbav:"default_term"`
	DefaultPayment         string   `json:"default_payment" dynamodbav:"default_payment"`
	DefaultCoverage        float64  `json:"default_coverage" dynamodbav:"default_coverage"`
	DefaultRampSchedule    string   `json:"default_ramp_schedule" dynamodbav:"default_ramp_schedule"`

	// GracePeriodDays is a per-provider window (in days) during which
	// just-purchased capacity is suppressed from the recommendations
	// list so users don't re-buy the same capacity while the cloud
	// provider's utilization metrics catch up. Keys are provider slugs
	// ("aws", "azure", "gcp"). Missing keys default to DefaultGracePeriodDays
	// (7). An explicit 0 disables suppression for that provider. Use
	// GracePeriodFor to read a specific provider's effective value (it
	// applies the default + safety clamp).
	GracePeriodDays map[string]int `json:"grace_period_days,omitempty" dynamodbav:"grace_period_days,omitempty"`

	// RI Exchange automation settings
	RIExchangeEnabled              bool    `json:"ri_exchange_enabled" dynamodbav:"ri_exchange_enabled"`
	RIExchangeMode                 string  `json:"ri_exchange_mode" dynamodbav:"ri_exchange_mode"`
	RIExchangeUtilizationThreshold float64 `json:"ri_exchange_utilization_threshold" dynamodbav:"ri_exchange_utilization_threshold"`
	RIExchangeMaxPerExchangeUSD    float64 `json:"ri_exchange_max_per_exchange_usd" dynamodbav:"ri_exchange_max_per_exchange_usd"`
	RIExchangeMaxDailyUSD          float64 `json:"ri_exchange_max_daily_usd" dynamodbav:"ri_exchange_max_daily_usd"`
	RIExchangeLookbackDays         int     `json:"ri_exchange_lookback_days" dynamodbav:"ri_exchange_lookback_days"`

	// RecommendationsCacheStaleHours is the age (hours) at which the
	// recommendations cache is considered stale and a background refresh
	// fires automatically (stale-while-revalidate). 0 disables automatic
	// background refresh; the cron scheduler and the manual Refresh button
	// still work regardless. Valid range: 0–8760 (up to one year).
	// Default: 24.
	RecommendationsCacheStaleHours int `json:"recommendations_cache_stale_hours" db:"recommendations_cache_stale_hours"`

	// RecommendationsLookbackDays is the AWS Cost Explorer lookback window
	// (days) used when fetching fresh recommendations. Must be one of 7,
	// 30, or 60 -- the AWS Cost Explorer LookbackPeriodInDays enum.
	// GCP CUD Recommender has no equivalent lookback parameter (fixed
	// internally); this setting applies to AWS only.
	// Default: 7.
	RecommendationsLookbackDays int `json:"recommendations_lookback_days" db:"recommendations_lookback_days"`

	// PurchaseDelayHours is the Gmail-style pre-fire delay (issue #291 wave-2).
	// When > 0, approving a purchase defers the actual cloud SDK call by this
	// many hours. The user receives a "scheduled, revoke before X" email
	// immediately after approval and may cancel at $0 until the window closes.
	// 0 means immediate-execute (backward compat). Valid range: [0, 168].
	// Default: 48.
	PurchaseDelayHours int `json:"purchase_delay_hours" db:"purchase_delay_hours"`

	// LadderingEnabled is the global kill-switch for the commitment-laddering
	// feature (issue #1333 phase 3). When false (the default), no laddering
	// engine runs fire regardless of per-account LadderConfig.Enabled settings.
	// Set to true to allow per-account configs to activate individually.
	LadderingEnabled bool `json:"laddering_enabled" db:"laddering_enabled"`

	// LadderExecutionEnabled gates the write side of the ladder capability
	// (migration 000083). BOTH LadderingEnabled AND LadderExecutionEnabled
	// must be true for PurchaseLayer / ReshapeBuffer to be wired with real
	// AWS SDK clients. Default false: existing deployments that enable
	// laddering produce plans but never call AWS purchase APIs until an
	// operator explicitly opts in. Fail-loud: wireLadderWriteSide returns
	// a typed ErrLadderExecutionDisabled when this is false.
	LadderExecutionEnabled bool `json:"ladder_execution_enabled" db:"ladder_execution_enabled"`

	// OfferingClass controls the EC2 Reserved Instance offering class used
	// during purchase. Accepted values: "convertible" (default) and
	// "standard". Convertible RIs can be exchanged for a different
	// instance family/size/region/OS; Standard RIs are locked to the exact
	// instance type for the full term but are ~5% cheaper.
	// Unknown values are rejected at purchase time with an explicit error.
	OfferingClass string `json:"offering_class,omitempty" dynamodbav:"offering_class,omitempty"`

	// RequireDifferentApprover enables 4-eyes approval mode (issue #1005).
	// When true, the user who created a purchase execution cannot approve it
	// themselves; a different person with approval rights must do so. This is
	// a standard SOX / SOC2 segregation-of-duties control. Default: false.
	// Admins who created an execution and need to approve it must disable this
	// mode first (the admin wildcard is NOT exempt from the restriction).
	RequireDifferentApprover bool `json:"require_different_approver" dynamodbav:"require_different_approver"`
}

GlobalConfig represents the global CUDly configuration.

func (*GlobalConfig) GetPurchaseDelay

func (g *GlobalConfig) GetPurchaseDelay() time.Duration

GetPurchaseDelay returns the pre-fire delay as a time.Duration. Nil receiver returns the default. Values outside [0, MaxPurchaseDelayHours] are clamped so a rogue DB write cannot break the scheduler.

func (*GlobalConfig) GracePeriodFor

func (g *GlobalConfig) GracePeriodFor(provider string) int

GracePeriodFor returns the effective grace-period window (in days) for the given provider slug ("aws", "azure", "gcp"). Returns the default when the provider has no explicit entry. Preserves an explicit 0 (which disables the feature for that provider). Clamps the result to [0, MaxGracePeriodDays] so a misconfigured DB row can't suppress recs indefinitely.

func (*GlobalConfig) Validate

func (c *GlobalConfig) Validate() error

Validate validates the GlobalConfig.

type LadderConfigDB

type LadderConfigDB struct {
	UpdatedAt time.Time `json:"updated_at"`
	CreatedAt time.Time `json:"created_at"`
	// MaxHourlyCommitPerRun caps the total hourly commitment delta a single run
	// may purchase. nil means no cap.
	MaxHourlyCommitPerRun      *float64        `json:"max_hourly_commit_per_run,omitempty"`
	CloudAccountID             string          `json:"cloud_account_id"`
	Provider                   string          `json:"provider"`
	Mode                       string          `json:"mode"`    // ladder.ModeEmailApproval | ladder.ModeAutoApprove
	Cadence                    string          `json:"cadence"` // ladder.CadenceDaily | ladder.CadenceWeekly
	ID                         string          `json:"id"`
	RampSchedule               json.RawMessage `json:"ramp_schedule"`
	BufferUtilizationThreshold float64         `json:"buffer_utilization_threshold"`
	LookbackDays               int             `json:"lookback_days"`
	MaxActionsPerRun           int             `json:"max_actions_per_run"`
	// BaselinePercentile is the statistical percentile used to anchor the base
	// commitment layer. Must be in (0, 50].
	BaselinePercentile float64 `json:"baseline_percentile"`
	BufferFraction     float64 `json:"buffer_fraction"`
	TargetCoverage     float64 `json:"target_coverage"`
	Enabled            bool    `json:"enabled"`
}

LadderConfigDB is the DB-persistence mirror of pkg/ladder.LadderConfig. It stores one per-account, per-provider ladder configuration row and is used by the store layer (GetLadderConfig / UpsertLadderConfig) and the API handler. Mode and Cadence are plain strings whose valid values are defined by pkg/ladder (ModeEmailApproval, ModeAutoApprove, CadenceDaily, CadenceWeekly). Validation calls pkg/ladder's Parse* functions so the internal/config package never redefines those constants.

MaxHourlyCommitPerRun is a pointer because nil means "no cap" (distinct from 0, which would cap all spending). All numeric money fields follow the project rule: absent = nil/pointer, never 0. Field order is optimized for govet fieldalignment (pointer-containing fields grouped first to shrink the GC pointer-scan range, then scalars, bool last). It intentionally does not follow the logical/SQL column order; see the scanLadderConfig / Upsert SQL for the wire order.

func (*LadderConfigDB) Validate

func (c *LadderConfigDB) Validate() error

Validate validates a LadderConfigDB before persist. It delegates mode and cadence checks to pkg/ladder's Parse* functions (single source of truth for those enums) and validates numeric bounds using the same invariants that pkg/ladder.LadderConfig.Validate() enforces.

Returns a specific, descriptive error on any violation. Callers must never silently default away a validation failure.

type LadderRunDB

type LadderRunDB struct {
	// Nullable monetary snapshot: nil means "not computed", never $0.
	BaselineUSDHr *float64 `json:"baseline_usd_hr,omitempty"`
	TargetUSDHr   *float64 `json:"target_usd_hr,omitempty"`
	ExistingUSDHr *float64 `json:"existing_usd_hr,omitempty"`
	GapUSDHr      *float64 `json:"gap_usd_hr,omitempty"`

	// Nullable FK and optional text fields (all pointer types).
	ConfigID               *string    `json:"config_id,omitempty"`
	CompletedAt            *time.Time `json:"completed_at,omitempty"`
	ApprovalTokenHash      *string    `json:"approval_token_hash,omitempty"`
	ApprovalTokenExpiresAt *time.Time `json:"approval_token_expires_at,omitempty"`
	ApprovedBy             *string    `json:"approved_by,omitempty"`
	CancelledBy            *string    `json:"cancelled_by,omitempty"`
	FireAt                 *time.Time `json:"fire_at,omitempty"`
	// Mode and Cadence are nullable in the DB (populated from LadderConfigDB
	// at run creation time; nil only for legacy / partially-failed rows).
	Mode    *string `json:"mode,omitempty"`
	Cadence *string `json:"cadence,omitempty"`

	// Plan JSON blob (JSONB, NOT NULL DEFAULT '{}').
	Plan json.RawMessage `json:"plan"`

	// Non-nullable accumulator totals (initialised to 0, not measurements;
	// zero is a meaningful value for these counters unlike the monetary snapshot).
	TotalHourlyCommit float64 `json:"total_hourly_commit"`
	TotalUpfrontCost  float64 `json:"total_upfront_cost"`
	EstimatedSavings  float64 `json:"estimated_savings"`

	// Required string / enum fields.
	ID     string           `json:"id"`
	Status ladder.RunStatus `json:"status"`

	// Required timestamps.
	StartedAt time.Time `json:"started_at"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

LadderRunDB mirrors the ladder_runs table (migration 000080). Monetary snapshot columns are *float64 (nullable, NEVER 0-coerced: NULL means "not computed", not "$0"). Field order minimizes GC pointer-scan range: explicit pointer fields come before scalars.

type LadderTrancheDB

type LadderTrancheDB struct {
	// Nullable FK pointers.
	ConfigID    *string `json:"config_id,omitempty"`
	RunID       *string `json:"run_id,omitempty"`
	ExecutionID *string `json:"execution_id,omitempty"` // references purchase_executions.execution_id

	// Required fields.
	ID            string               `json:"id"`
	LayerType     ladder.LayerType     `json:"layer_type"`
	Term          ladder.Term          `json:"term"`
	PaymentOption ladder.PaymentOption `json:"payment_option"`
	Status        ladder.TrancheStatus `json:"status"`

	// Monetary and timing.
	AmountUSDHr   float64   `json:"amount_usd_hr"`
	ScheduledDate time.Time `json:"scheduled_date"`
	CreatedAt     time.Time `json:"created_at"`
}

LadderTrancheDB mirrors the ladder_tranches table (migration 000081). One row per ramp step per allocation; persisted as status=scheduled audit rows only in PR-2 (no firing sweep wired yet). Field order minimizes GC pointer-scan range.

type PaymentSchedule

type PaymentSchedule string

PaymentSchedule is the cash-flow shape a payment-option token implies, stripped of the provider-specific spelling of that token. Two tokens that map to the same PaymentSchedule bill the customer identically; only the word differs (AWS spells all-upfront what Azure spells upfront, and AWS spells no-upfront what Azure and GCP spell monthly).

It exists so callers can tell a rename apart from a real change when NormalizePaymentOption rewrites a token: a rename is bookkeeping, a real change moves the customer's money and has to be disclosed (#1503).

const (
	// PaymentScheduleUpfront: the whole commitment is charged once, at purchase.
	PaymentScheduleUpfront PaymentSchedule = "upfront"
	// PaymentSchedulePartialUpfront: part is charged at purchase, the rest recurs.
	PaymentSchedulePartialUpfront PaymentSchedule = "partial-upfront"
	// PaymentScheduleRecurring: nothing is charged at purchase; the commitment
	// is billed per period across the term.
	PaymentScheduleRecurring PaymentSchedule = "recurring"
	// PaymentScheduleUnknown: the token is not one of the modeled schedules.
	// Two unrecognized tokens compare equal under this classification, so
	// callers that must distinguish them have to compare the raw tokens too.
	// In practice unmapped tokens never reach a coercion comparison:
	// NormalizePaymentOption returns ok=false for them and the validator
	// rejects them at the next boundary.
	PaymentScheduleUnknown PaymentSchedule = "unknown"
)

func PaymentScheduleFor

func PaymentScheduleFor(token string) PaymentSchedule

PaymentScheduleFor classifies a payment-option token by the billing schedule it implies. The token must already be lowercased and trimmed (validatePurchaseRecommendation and NormalizePaymentOption both work on such tokens).

type PostgresStore

type PostgresStore struct {
	// contains filtered or unexported fields
}

PostgresStore implements StoreInterface using PostgreSQL.

func NewPostgresStore

func NewPostgresStore(db *database.Connection) *PostgresStore

NewPostgresStore creates a new PostgreSQL-backed config store.

func (*PostgresStore) CancelAllPendingExchanges

func (s *PostgresStore) CancelAllPendingExchanges(ctx context.Context) (int64, error)

CancelAllPendingExchanges cancels all pending RI exchange records regardless of origin. Kept for interface compatibility; new callers should prefer CancelPendingExchangesByOrigin to avoid cross-origin contamination.

func (*PostgresStore) CancelExecutionAtomic

func (s *PostgresStore) CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (canceled bool, currentStatus string, err error)

CancelExecutionAtomic atomically transitions an execution from pending or notified to 'canceled' (canonical US spelling), setting canceled_by to the supplied actor (NULL when actor is nil). The UPDATE is conditional on status IN ('pending','notified') so a concurrent approve that has already transitioned the row to 'approved' causes zero rows to be affected and the method returns (false, currentStatus, nil) with the live status fetched via a follow-up SELECT. Returns (true, "canceled", nil) on success and (false, "", err) on a real DB error.

The 'scheduled' status is intentionally NOT accepted here -- the Gmail-style pre-fire delay revoke flow has its own CancelScheduledExecutionAtomic so the two paths surface distinct CAS race outcomes (cancel returns 409 on already-approved; scheduled-revoke returns 410 "window closed" on already-fired).

Callers must run the suppression cleanup in the same transaction; use the WithTx + DeleteSuppressionsByExecutionTx pairing at the call site exactly as the old SavePurchaseExecutionTx path did, except now the status guard is inside the UPDATE rather than checked optimistically before entering the tx.

func (*PostgresStore) CancelPendingExchangesByOrigin

func (s *PostgresStore) CancelPendingExchangesByOrigin(ctx context.Context, origin common.ExchangeOrigin) (int64, error)

CancelPendingExchangesByOrigin cancels only pending records that match the given origin (gap G10 / issue #1348):

  • common.ExchangeOriginStandalone: cancels WHERE ladder_run_id IS NULL
  • common.ExchangeOriginLadder: cancels WHERE ladder_run_id IS NOT NULL

The origin is validated at this boundary; an unknown value fails loud rather than silently canceling the wrong partition on a money path.

DELIBERATE COARSE PARTITION: the ExchangeOriginLadder branch cancels EVERY ladder-linked pending record (ladder_run_id IS NOT NULL) across ALL ladder runs and configs, not just the current run's. This is acceptable today because the ladder never creates pending exchange records: buildRIExchangeConfig forces Mode=auto, which completes or fails immediately without leaving a pending row. Per-run / per-config scoping needs an additional selection key (e.g. the specific ladder_run_id or config_id) and is tracked in TODO(#1367).

func (*PostgresStore) CancelScheduledExecutionAtomic

func (s *PostgresStore) CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (canceled bool, currentStatus string, err error)

CancelScheduledExecutionAtomic atomically transitions an execution from 'scheduled' to 'canceled' (canonical US spelling), setting canceled_by to the supplied actor (NULL when actor is nil). Used by the Gmail-style pre-fire delay revoke path (issue #290 / #291 wave-2): an approved-but-not-yet-fired execution can be revoked at $0 by flipping it to canceled before the scheduler fires the cloud SDK call.

The 'scheduled' status is the only accepted source. A concurrent scheduler tick that already transitioned the row to 'approved' or 'running' causes zero rows to be affected and the method returns (false, currentStatus, nil) -- the caller maps that to a 410 ("revocation window has closed") so the frontend can fall through to the post-execution Azure direct-cancel API path.

Returns (true, StatusCanceled, nil) on success and (false, "", err) on a real DB error. Must be called inside a WithTx block so the suppression cleanup commits atomically with the status flip.

func (*PostgresStore) ClaimMarketplaceListingSlot

func (s *PostgresStore) ClaimMarketplaceListingSlot(ctx context.Context, purchaseID string) (bool, error)

ClaimMarketplaceListingSlot atomically reserves the marketplace-listing slot for a purchase_history row so two concurrent marketplace-list requests cannot both proceed to create a duplicate AWS listing (issue #292). The single conditional UPDATE transitions listing_state to ListingStatePending only when the row is not already active or pending, so exactly one racing request wins. It returns (true, nil) when this call reserved the slot and (false, nil) when the row is already active/pending (or absent); the caller maps false to a 409.

func (*PostgresStore) ClaimRIExchangeIdempotencyKey

func (s *PostgresStore) ClaimRIExchangeIdempotencyKey(ctx context.Context, key string, window time.Duration) (bool, error)

ClaimRIExchangeIdempotencyKey atomically claims an RI exchange submit fingerprint (issue #1642). See the interface doc for the contract.

The whole decision is one statement, so two concurrent submits of the same fingerprint cannot both win: the INSERT and the conditional takeover of an expired row are the same atomic row operation, with no read-then-write window between them. RowsAffected() is 1 only when this call inserted the row or took over an expired one; the ON CONFLICT ... WHERE predicate evaluating false leaves it at 0.

Both the write and the expiry comparison use now(), the DATABASE clock, so clock skew between concurrent application instances cannot distort the window.

func (*PostgresStore) CleanupOldExecutions

func (s *PostgresStore) CleanupOldExecutions(ctx context.Context, retentionDays int) (int64, error)

CleanupOldExecutions deletes purchase executions older than retentionDays.

Three independent cleanup branches, each with its own retention window so that a row far in one dimension doesn't block cleanup in the other:

  1. Completed-state cleanup: `status = 'completed' AND scheduled_date < NOW() - retention`. Keeps recent completions visible in the UI for at least `retention` days before purging. `scheduled_date` is the right clock here because a completed row executed on (or very near) the day it was scheduled for.

  2. Canceled-state cleanup: `status IN ('cancelled', 'canceled') AND updated_at < NOW() - retention`. Both spellings are included to cover legacy rows ('cancelled') and new code rows ('canceled', canonical US spelling per StatusCanceled / migration 000089).

    This branch retains on `updated_at`, NOT `scheduled_date`, and must stay that way: CountExecutionsByPlanAndStatus windows the plan health score's canceled count on `updated_at` over exactly this retention period (see internal/api/plan_health.go planHealthLookbackDays), and CancelExecutionAtomic stamps `updated_at` while leaving `scheduled_date` untouched. Retaining on `scheduled_date` therefore purged rows the score was still counting: cancel a purchase whose scheduled_date is already past the horizon (a plan's rows are created up front, and parseCreatePurchasesRequest accepts a past start date) and the next sweep deleted it the same day, silently raising that plan's health score by up to 20 points overnight with no operator action. Nothing accumulates indefinitely: a genuinely old canceled row still has an old `updated_at`.

    This cuts the other way too, and the change is user-visible: a canceled row with a FUTURE `scheduled_date` used to be retained forever, because `scheduled_date < NOW() - retention` was never true for it. It now leaves History `retention` days after the cancellation rather than `retention` days after the date it was scheduled for, so a purchase canceled today for a 2-year-out date disappears in a month instead of in two years. That closes an unbounded-retention leak and is the behavior the health window already assumed.

  3. Expired-row cleanup: `expires_at IS NOT NULL AND expires_at < NOW() - retention`, for any status the health score does not count. This is the row's own TTL column (SavePurchaseExecution writes it from PurchaseExecution.TTL), NOT the approval-token deadline -- that is a separate column, `approval_token_expires_at`, added by migration

  4. A row whose TTL lapsed longer than `retention` ago is dead, so without this branch it would accumulate indefinitely.

    Every status in HealthScoredExecutionStatuses is excluded here, which is the whole reason that slice is exported rather than spelled out twice. `expires_at` is written once at insert and never rewritten when a row later reaches a terminal status, so it can be arbitrarily older than the terminal transition. Without the exclusion, a row that failed or was canceled TODAY but carries a lapsed `expires_at` is purged by this branch the same day, while the score is still counting it -- reintroducing through this branch the exact overnight score jump branch 2 exists to prevent, and at up to -40 for `failed`, twice canceled's worst case. Their own terminal-status branches govern them.

    `failed` deliberately has no cleanup branch of its own: the health score's lookback comment (internal/api/plan_health.go planHealthLookbackDays) documents that this sweep "never deletes failed ones", and the score's window depends on that being true.

    Note on reachability: `expires_at` currently has exactly one writer, SavePurchaseExecution via timeFromTTL(execution.TTL), and no production path assigns PurchaseExecution.TTL, so rows created by current code leave it NULL and this branch is inert for them. It can still hold non-NULL values on legacy imported rows, which is why the exclusion is written defensively rather than omitted.

The branches are OR'd: a row that qualifies under ANY is deleted, regardless of the other columns. An earlier revision of this function incorrectly AND'd the `scheduled_date` gate with both branches, which meant pending rows with a far-future `scheduled_date` but a long-past `expires_at` never got cleaned up (a 2-year-out purchase whose row TTL lapsed would leave a dead row accumulating for 1.9 years). Keep each branch fully parenthesized so a future edit can't change the predicate's meaning through AND/OR precedence.

NULL `expires_at` is excluded from branch 3 so rows that never had a TTL are safe from expiry-based cleanup -- which, per the reachability note above, is every row current code writes.

func (*PostgresStore) ClearCollectionStarted

func (s *PostgresStore) ClearCollectionStarted(ctx context.Context, token string) error

ClearCollectionStarted clears last_collection_started_at (and the owner token) so the frontend knows an async collection has finished. Called by the scheduler on both success and failure paths. On the success path, last_collected_at and last_collection_error are updated by UpsertRecommendations/ReplaceRecommendations, so this method only touches started_at and the owner column.

The clear is scoped to rows where last_collection_owner_id still matches token (issue #261): a caller whose token no longer matches (another run has since started and won the race) has nothing left to clear and this is a documented silent no-op, not an error. An empty token is a boundary error: only a caller that actually won MarkCollectionStarted should ever call Clear; callers with no marker to own (cron, cold-start) must skip the call entirely rather than pass an empty token.

func (*PostgresStore) ClearRevocationInFlight

func (s *PostgresStore) ClearRevocationInFlight(ctx context.Context, purchaseID string) error

ClearRevocationInFlight sets revocation_in_flight=false on a purchase_history row. Called when the Azure Return call fails transiently (before Azure actually issued a refund) so the row is not left stuck in the in-flight state, which would mislead the finalize_revocations sweep into thinking Azure succeeded (issue #290, second-wave CR Finding D). No-op when already false.

func (*PostgresStore) CompletePlanStep

func (s *PostgresStore) CompletePlanStep(ctx context.Context, planID string, stepNumber int) error

CompletePlanStep records that ramp step stepNumber of planID completed and advances the schedule to it, inside a transaction. The row is locked with SELECT FOR UPDATE so concurrent callers (overlapping Lambda invocations, multi-tick cron) cannot both read the same CurrentStep value and both write CurrentStep+1, skipping a step (issue #1071).

The operation is idempotent in stepNumber rather than a blind increment: a multi-account plan produces one execution per account per ramp step, and retrying two separately-failed accounts of the same step used to advance the ramp twice, so the plan reported itself a step further along than the commitment it had actually bought (issue #1669). Completing a step at or below CurrentStep therefore writes nothing and reports ErrRampStepAlreadyCounted.

Completing a step more than one beyond CurrentStep is refused for the same reason in the other direction: the steps in between never completed, and advancing over them would silently overstate how much the plan has bought.

Granularity: a step is complete when EVERY cloud account it fanned out to has bought, not when any one of them has (issue #1861) -- see requireRampStepBought. Until then the advance reports ErrRampStepIncomplete and the plan row is left untouched.

Returns nil when the plan no longer exists (deleted between execution and progress update) so the caller is not penalized for a race it cannot control.

func (*PostgresStore) CompleteRIExchange

func (s *PostgresStore) CompleteRIExchange(ctx context.Context, id, exchangeID string) error

CompleteRIExchange marks an RI exchange as completed.

func (*PostgresStore) CompleteRIExchangeWithPayment

func (s *PostgresStore) CompleteRIExchangeWithPayment(ctx context.Context, id, exchangeID, acceptedPaymentDue string) error

CompleteRIExchangeWithPayment marks an RI exchange as completed and updates payment_due to the amount AWS actually accepted. Use this on the manual-approval path instead of CompleteRIExchange so the daily-spend ledger (GetRIExchangeDailySpend) reflects the accepted amount, not the stale pre-execution quote (H3 fix).

func (*PostgresStore) CountExecutionsByPlanAndStatus

func (s *PostgresStore) CountExecutionsByPlanAndStatus(ctx context.Context, statuses []string, since time.Time) (map[string]ExecutionStatusCounts, error)

CountExecutionsByPlanAndStatus returns, keyed by plan ID, the exact number of executions in each of the supplied statuses that last changed state at or after `since`.

Aggregated with GROUP BY rather than counted from a GetExecutionsByStatuses page: that method's DESC + LIMIT truncation would silently understate any plan whose executions fall outside the newest `limit` rows, which for the plan health score means an unhealthy plan quietly renders as healthy once newer rows from other plans push its failures out of the window. The result set here is bounded by plans x statuses, not by execution volume, so it needs no limit of its own.

The window is on updated_at, NOT scheduled_date, because for the terminal statuses this method exists to count those are different dates pointing in opposite directions. A plan's executions are created up front for the whole ramp, so a pending row carries a scheduled_date months in the FUTURE; canceling it (CancelExecutionAtomic) leaves that future date untouched. Windowing on scheduled_date would therefore count a purchase cancelled today under a date next year, and answer "how many rows are scheduled recently-or-later" instead of "how many changed state recently". updated_at is stamped by CancelExecutionAtomic and TransitionExecutionStatus, and backstopped by the update_purchase_executions_updated_at trigger, so it is when the row actually entered the status being counted.

plan_id has been nullable since migration 000033 (direct-execute purchases from the Recommendations page have no originating plan, and deleting a plan SET NULLs its executions). Those rows belong to no plan and are excluded in SQL; the scan still goes through sql.NullString so a NULL can never turn a per-plan count into a scan error that blanks the score for every plan.

func (*PostgresStore) CountPendingExecutionsForAccount

func (s *PostgresStore) CountPendingExecutionsForAccount(ctx context.Context, accountID string) (int, error)

CountPendingExecutionsForAccount returns the number of pending/notified purchase executions still referencing this cloud account. The deleteAccount handler calls this before issuing DELETE FROM cloud_accounts so it can short-circuit with a 409 instead of letting migration 000053's ON DELETE RESTRICT bubble up as an opaque FK-violation error. See issue #606.

func (*PostgresStore) CreateAccountRegistration

func (s *PostgresStore) CreateAccountRegistration(ctx context.Context, reg *AccountRegistration) error

CreateAccountRegistration inserts a new registration request. Returns an error wrapping "duplicate" when the partial unique index rejects a second pending registration for the same provider+external_id.

func (*PostgresStore) CreateCloudAccount

func (s *PostgresStore) CreateCloudAccount(ctx context.Context, account *CloudAccount) error

CreateCloudAccount inserts a new cloud account record.

func (*PostgresStore) CreatePurchasePlan

func (s *PostgresStore) CreatePurchasePlan(ctx context.Context, plan *PurchasePlan) error

CreatePurchasePlan creates a new purchase plan.

func (*PostgresStore) CreateSuppression

func (s *PostgresStore) CreateSuppression(ctx context.Context, sup *PurchaseSuppression) error

CreateSuppression inserts a suppression row using a one-call transaction. Callers that need to bundle this write with other statements (e.g. the matching execution insert) should use CreateSuppressionTx inside a WithTx block instead.

func (*PostgresStore) CreateSuppressionTx

func (s *PostgresStore) CreateSuppressionTx(ctx context.Context, tx pgx.Tx, sup *PurchaseSuppression) error

CreateSuppressionTx inserts a suppression row inside a caller-owned transaction. sup.ID is populated from the Postgres-generated UUID if the caller left it blank.

func (*PostgresStore) DeleteAccountCredentials

func (s *PostgresStore) DeleteAccountCredentials(ctx context.Context, accountID string) error

DeleteAccountCredentials removes all credential records for an account.

func (*PostgresStore) DeleteAccountRegistration

func (s *PostgresStore) DeleteAccountRegistration(ctx context.Context, id string) error

DeleteAccountRegistration removes a registration record by ID.

func (*PostgresStore) DeleteAccountServiceOverride

func (s *PostgresStore) DeleteAccountServiceOverride(ctx context.Context, accountID, provider, service string) error

DeleteAccountServiceOverride removes an override, reverting to global defaults.

func (*PostgresStore) DeleteCloudAccount

func (s *PostgresStore) DeleteCloudAccount(ctx context.Context, id string) error

DeleteCloudAccount deletes a cloud account. Cascades to credentials and overrides. If an approved account_registrations row points at this account, it is reset to 'pending' in the same transaction so the admin can re-approve through the normal flow instead of being left with a dead-end "Approved (account pending link)" row.

func (*PostgresStore) DeletePurchasePlan

func (s *PostgresStore) DeletePurchasePlan(ctx context.Context, planID string) error

DeletePurchasePlan deletes a purchase plan.

func (*PostgresStore) DeleteSuppressionsByExecution

func (s *PostgresStore) DeleteSuppressionsByExecution(ctx context.Context, executionID string) error

DeleteSuppressionsByExecution deletes all suppression rows for the given execution using a one-call transaction. Used by the cancel path when the whole execution's capacity should be un-suppressed.

func (*PostgresStore) DeleteSuppressionsByExecutionTx

func (s *PostgresStore) DeleteSuppressionsByExecutionTx(ctx context.Context, tx pgx.Tx, executionID string) error

DeleteSuppressionsByExecutionTx deletes all suppression rows for the given execution inside a caller-owned transaction. Safe to call on an execution with no suppression rows (e.g. grace_period_days=0 at insert time) — returns nil, no error.

func (*PostgresStore) FailRIExchange

func (s *PostgresStore) FailRIExchange(ctx context.Context, id, errorMsg string) error

FailRIExchange marks an RI exchange as failed.

func (*PostgresStore) FlipPurchaseRevocationInFlight

func (s *PostgresStore) FlipPurchaseRevocationInFlight(ctx context.Context, purchaseID string) error

FlipPurchaseRevocationInFlight sets revocation_in_flight=true on the purchase_history row for purchaseID. Called immediately before the Azure Return API call to enable partial-success reconciliation (issue #290 Finding #6). Idempotent: already-true rows are not modified. Returns a not-found error when no row matches.

func (*PostgresStore) GetAccountCredential

func (s *PostgresStore) GetAccountCredential(ctx context.Context, accountID, credentialType string) (string, error)

GetAccountCredential returns the encrypted blob for an account credential.

func (*PostgresStore) GetAccountRegistration

func (s *PostgresStore) GetAccountRegistration(ctx context.Context, id string) (*AccountRegistration, error)

GetAccountRegistration returns a single registration by UUID.

func (*PostgresStore) GetAccountRegistrationByToken

func (s *PostgresStore) GetAccountRegistrationByToken(ctx context.Context, token string) (*AccountRegistration, error)

GetAccountRegistrationByToken returns a registration by its reference_token.

func (*PostgresStore) GetAccountServiceOverride

func (s *PostgresStore) GetAccountServiceOverride(ctx context.Context, accountID, provider, service string) (*AccountServiceOverride, error)

GetAccountServiceOverride returns a single override, or nil if none exists.

func (*PostgresStore) GetActivePurchaseHistory

func (s *PostgresStore) GetActivePurchaseHistory(ctx context.Context, asOf time.Time, accountIDs []string, externalIDsByProvider map[string][]string) ([]PurchaseHistoryRecord, error)

GetActivePurchaseHistory retrieves every purchase_history row still within its commitment term at asOf, optionally scoped to a set of accounts via the same dual-column predicate as GetPurchaseHistoryFiltered (appendAccountPredicate): both accountIDs (cloud_accounts UUIDs matched on cloud_account_id) and externalIDsByProvider (provider-scoped external numbers matched on account_id) are applied with OR semantics so rows carrying only one identifier are still returned (issues #701/#498/#866). Both empty means all accounts. The active filter is pushed into SQL so the result is bounded by the number of live commitments (not by all history ever recorded), which is what the analytics collector, dashboard KPIs, and inventory endpoints need: it cannot silently truncate older-but-still-active 1y/3y commitments the way a newest-first LIMIT page does (issue #1140). term*8760 hours matches the collector's HoursPerYear and the API layer's commitmentExpiry (both 365*24) so the SQL and Go term windows agree. The expiry comparison is inclusive (expiry >= asOf): a commitment expiring exactly at asOf is still active, matching the API layer's isActiveCommitment (!now.After(expiry)) so the SQL result set and the Go-side active checks share one boundary definition.

func (*PostgresStore) GetAllPurchaseHistory

func (s *PostgresStore) GetAllPurchaseHistory(ctx context.Context, limit int) ([]PurchaseHistoryRecord, error)

GetAllPurchaseHistory retrieves all purchase history.

func (*PostgresStore) GetCloudAccount

func (s *PostgresStore) GetCloudAccount(ctx context.Context, id string) (*CloudAccount, error)

GetCloudAccount returns a single cloud account by ID with credentials_configured derived.

func (*PostgresStore) GetCloudAccountByExternalID

func (s *PostgresStore) GetCloudAccountByExternalID(ctx context.Context, provider, externalID string) (*CloudAccount, error)

GetCloudAccountByExternalID returns a single cloud account matched by (provider, external_id). Used by the scheduler ambient-path tagging fix for issue #604 — when the Lambda's STS identity matches a registered account (regardless of enabled state), rec rows are stamped with that account's UUID so the approve modal shows the account name instead of `(ambient)`. Returns (nil, nil) when no row matches.

The cloud_accounts table declares `UNIQUE(provider, external_id)` (migration 000011), which guarantees the lookup hits an index.

func (*PostgresStore) GetExecutionByID

func (s *PostgresStore) GetExecutionByID(ctx context.Context, executionID string) (*PurchaseExecution, error)

GetExecutionByID retrieves a purchase execution by execution ID. Returns an error wrapping ErrNotFound when no row matches executionID so callers can cleanly distinguish "not found" (errors.Is(err, ErrNotFound)) from a real DB failure (any other non-nil error). A nil error guarantees a non-nil execution (fail-loud contract; issues #976, #1339).

func (*PostgresStore) GetExecutionByPlanAndDate

func (s *PostgresStore) GetExecutionByPlanAndDate(ctx context.Context, planID string, scheduledDate time.Time) (*PurchaseExecution, error)

GetExecutionByPlanAndDate retrieves execution for a specific plan and date.

func (*PostgresStore) GetExecutionsByStatuses

func (s *PostgresStore) GetExecutionsByStatuses(ctx context.Context, statuses []string, limit int) ([]PurchaseExecution, error)

GetExecutionsByStatuses returns executions whose Status is any of the supplied values, newest-first, capped at `limit`. Used by the History handler to merge pending/failed/expired rows alongside completed purchases without changing the narrower GetPendingExecutions contract the scheduler depends on.

func (*PostgresStore) GetGlobalConfig

func (s *PostgresStore) GetGlobalConfig(ctx context.Context) (*GlobalConfig, error)

GetGlobalConfig retrieves the global configuration.

func (*PostgresStore) GetInFlightLadderCommitUSDHr

func (s *PostgresStore) GetInFlightLadderCommitUSDHr(ctx context.Context, configID string) (*float64, error)

GetInFlightLadderCommitUSDHr returns the total hourly USD commitment already in flight for the given config: the SUM of amount_usd_hr for ladder_tranches rows where config_id=$1 and status = 'scheduled'.

SCHEDULED ONLY: fired/completed tranches are executed purchases already counted in the engine's ExistingUSDPerHour (the provider adapters fold payment-pending and active commitments into E). Summing them here as well would double-subtract them from the gap and cause under-purchasing. Only not-yet-fired (scheduled) tranches are genuinely "in flight" and absent from E, so they alone must be netted out of the gap.

Returns a non-nil *float64 (zero when no scheduled tranches exist); never returns nil without a non-nil error, so callers can pass it directly to AllocationInput.InFlightUSDPerHour without an extra nil-guard.

func (*PostgresStore) GetLadderConfig

func (s *PostgresStore) GetLadderConfig(ctx context.Context, cloudAccountID, provider string) (*LadderConfigDB, error)

GetLadderConfig returns the ladder_config row for the given (cloud_account_id, provider) pair. Returns (nil, nil) when no row exists.

func (*PostgresStore) GetLadderConfigs

func (s *PostgresStore) GetLadderConfigs(ctx context.Context) ([]LadderConfigDB, error)

GetLadderConfigs returns all ladder_configs rows, newest first. Returns an empty slice (not nil) when no rows exist.

func (*PostgresStore) GetLadderRun

func (s *PostgresStore) GetLadderRun(ctx context.Context, id string) (*LadderRunDB, error)

GetLadderRun returns the ladder_runs row for the given id, or (nil, nil) when no row exists.

func (*PostgresStore) GetPendingExecutions

func (s *PostgresStore) GetPendingExecutions(ctx context.Context) ([]PurchaseExecution, error)

GetPendingExecutions retrieves all pending purchase executions.

func (*PostgresStore) GetPendingExecutionsTx

func (s *PostgresStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ([]PurchaseExecution, error)

GetPendingExecutionsTx is the tx-accepting variant of GetPendingExecutions. Running the read inside the same transaction as the subsequent insert makes duplicate-detection and execution creation atomic, closing the TOCTOU race in executePurchase (issue #643).

func (*PostgresStore) GetPlanAccounts

func (s *PostgresStore) GetPlanAccounts(ctx context.Context, planID string) ([]CloudAccount, error)

GetPlanAccounts returns all cloud accounts associated with a plan.

func (*PostgresStore) GetPlannedExecutions

func (s *PostgresStore) GetPlannedExecutions(ctx context.Context, statuses []string, limit int) ([]PurchaseExecution, error)

GetPlannedExecutions returns executions whose Status is any of the supplied values, ordered by scheduled_date ASC (soonest first), capped at `limit`. Used by the Planned Purchases handler where the user expects to act on imminent rows first.

Distinct from GetExecutionsByStatuses (DESC + LIMIT for History's newest-first semantics): when total rows exceed `limit`, a DESC truncation drops the soonest rows, exactly the ones this list must surface. Sorting the already-truncated subset in-memory cannot recover them.

Secondary sort by id ASC keeps ordering stable when multiple rows share a scheduled_date. NULLS LAST is defensive: the schema makes scheduled_date NOT NULL today, but the clause guards against a future relaxation silently hiding rows at the top of the list.

func (*PostgresStore) GetPurchaseHistory

func (s *PostgresStore) GetPurchaseHistory(ctx context.Context, accountID string, limit int) ([]PurchaseHistoryRecord, error)

GetPurchaseHistory retrieves purchase history for an account.

func (*PostgresStore) GetPurchaseHistoryByPurchaseID

func (s *PostgresStore) GetPurchaseHistoryByPurchaseID(ctx context.Context, purchaseID string) (*PurchaseHistoryRecord, error)

GetPurchaseHistoryByPurchaseID returns the single purchase_history row whose purchase_id matches purchaseID. Returns (nil, nil) when the row does not exist. The revocation-window columns (revocation_window_closes_at, revoked_at, revoked_via, support_case_id) are read alongside the base columns so the revoke endpoint can check idempotency without a second round trip (issue #290). The marketplace columns (offering_class, listing_id, listing_state) are read so the marketplace-list handler can validate offering_class and look up the cloud account (issue #292).

func (*PostgresStore) GetPurchaseHistoryFiltered

func (s *PostgresStore) GetPurchaseHistoryFiltered(
	ctx context.Context,
	filter PurchaseHistoryFilter,
) ([]PurchaseHistoryRecord, error)

GetPurchaseHistoryFiltered reads purchase_history rows matching the supplied filter set, newest-first, capped at filter.Limit. See the StoreInterface docstring and PurchaseHistoryFilter for the per-field semantics. Each WHERE clause is appended only when its field is populated, so an empty filter gets the same plan-shape as GetAllPurchaseHistory. Implementation mirrors buildRecommendationFilter (store_postgres_recommendations.go).

The account predicate matches BOTH identifier columns:

(cloud_account_id = ANY($uuids)
   OR (provider = $p AND account_id = ANY($extsForP)) OR ...)

purchase_history carries two account identifiers and either may be the only one populated on a given row: cloud_account_id (the cloud_accounts UUID FK, added in migration 000011 with no backfill, so NULL on every direct-execute / ambient / pre-000011 row) and account_id (the cloud-provider external number, e.g. an AWS account number, always populated). The top-bar Account chip emits the UUID, so a UUID-only predicate silently dropped every NULL-cloud_account_id row (issue #701/#498) while an external-only predicate dropped every row that only has the UUID (issue #866). Matching both columns includes rows written by either path. The caller resolves the requested UUIDs to their (provider, external_id) pairs scoped to the user's accessible accounts and groups the external ids by provider, so the external-id half stays provider-scoped and a reused external number (aws/123 vs azure/123) cannot leak the wrong rows.

func (*PostgresStore) GetPurchaseHistoryInFlight

func (s *PostgresStore) GetPurchaseHistoryInFlight(ctx context.Context) ([]*PurchaseHistoryRecord, error)

GetPurchaseHistoryInFlight returns all purchase_history rows with revocation_in_flight=true and revoked_at IS NULL. Used by the finalize_revocations scheduled sweep to retry MarkPurchaseRevoked for rows where the Azure Return succeeded but the subsequent DB write failed (issue #290 Finding #6).

func (*PostgresStore) GetPurchasePlan

func (s *PostgresStore) GetPurchasePlan(ctx context.Context, planID string) (*PurchasePlan, error)

GetPurchasePlan retrieves a purchase plan by ID.

func (*PostgresStore) GetRIExchangeDailySpend

func (s *PostgresStore) GetRIExchangeDailySpend(ctx context.Context, date time.Time) (string, error)

GetRIExchangeDailySpend returns total payment_due for completed and in-flight (processing) exchanges on a given date (UTC).

M5 fix: including 'processing' rows prevents a TOCTOU race where two concurrent approvals both read the same daily-spend total (before either exchange's ledger row is committed) and together exceed the daily cap. For 'completed' rows the time anchor is completed_at; for 'processing' rows it is updated_at (the moment the record transitioned to processing, i.e. when it was approved).

func (*PostgresStore) GetRIExchangeHistory

func (s *PostgresStore) GetRIExchangeHistory(ctx context.Context, since time.Time, limit int) ([]RIExchangeRecord, error)

GetRIExchangeHistory retrieves RI exchange history records.

func (*PostgresStore) GetRIExchangeRecord

func (s *PostgresStore) GetRIExchangeRecord(ctx context.Context, id string) (*RIExchangeRecord, error)

GetRIExchangeRecord retrieves an RI exchange record by ID.

func (*PostgresStore) GetRIExchangeRecordByToken

func (s *PostgresStore) GetRIExchangeRecordByToken(ctx context.Context, token string) (*RIExchangeRecord, error)

GetRIExchangeRecordByToken retrieves an RI exchange record by approval token.

func (*PostgresStore) GetRIUtilizationCache

func (s *PostgresStore) GetRIUtilizationCache(ctx context.Context, region string, lookbackDays int) (*RIUtilizationCacheEntry, error)

GetRIUtilizationCache returns the cached Cost Explorer utilization result for (region, lookback_days) or nil if no row exists. Staleness is evaluated by the caller — the query doesn't filter on fetched_at so a caller with a longer TTL than expected can still use the row.

func (*PostgresStore) GetRecommendationsFreshness

func (s *PostgresStore) GetRecommendationsFreshness(ctx context.Context) (*RecommendationsFreshness, error)

GetRecommendationsFreshness returns the singleton freshness row. The table is seeded with id=1 by the migration so a row always exists; LastCollectedAt, LastCollectionError, and LastCollectionStartedAt can be NULL.

func (*PostgresStore) GetScheduledExecutionsDue

func (s *PostgresStore) GetScheduledExecutionsDue(ctx context.Context) ([]PurchaseExecution, error)

GetScheduledExecutionsDue returns purchase_executions with status='scheduled' whose scheduled_execution_at has elapsed (scheduled_execution_at <= NOW()). Used by the Gmail-style pre-fire delay scheduler tick (issue #291 wave-2). Results are ordered oldest-due-first so the scheduler fires them in FIFO order. Capped at MaxListLimit per sweep to bound the per-tick blast radius.

func (*PostgresStore) GetServiceConfig

func (s *PostgresStore) GetServiceConfig(ctx context.Context, provider, service string) (*ServiceConfig, error)

GetServiceConfig retrieves configuration for a specific service.

func (*PostgresStore) GetStaleApprovedExecutions

func (s *PostgresStore) GetStaleApprovedExecutions(ctx context.Context, olderThan time.Duration) ([]PurchaseExecution, error)

GetStaleApprovedExecutions returns executions stuck in the "approved" status whose last update is older than olderThan. These are executions that were flipped to "approved" by ApproveAndExecute but whose synchronous purchase run never finalized (Lambda timeout, cold-start eviction, panic) — issue #632. updated_at is stamped to NOW() at the moment of the approved transition (see TransitionExecutionStatus) and is not touched again unless the run finalizes, so it is the age of the strand. Mirrors GetStaleProcessingExchanges.

func (*PostgresStore) GetStaleProcessingExchanges

func (s *PostgresStore) GetStaleProcessingExchanges(ctx context.Context, olderThan time.Duration) ([]RIExchangeRecord, error)

GetStaleProcessingExchanges returns processing exchanges older than the given duration.

func (*PostgresStore) GetStuckRampSteps

func (s *PostgresStore) GetStuckRampSteps(ctx context.Context) (map[string]RampStepBlock, error)

GetStuckRampSteps returns, keyed by plan ID, the plan's next ramp step and how many of that step's units are stuck on it -- never bought, latest attempt terminal and unsuccessful, no retry in flight.

Derived on every read rather than stamped on a row when the advance was refused, which is what makes it safe to score a plan's health on: a stamped refusal is true only at the instant it is written and nothing clears it, so a plan that recovered would stay marked unhealthy until an operator noticed. Recomputing from the same rows the advance gate reads means the report disappears exactly when the ramp unfreezes, and cannot drift from the gate.

Plans with nothing stuck are absent from the map rather than present with a zero, so a caller cannot confuse "healthy" with "not reported".

func (*PostgresStore) GetUserEmailByID

func (s *PostgresStore) GetUserEmailByID(ctx context.Context, userID string) (string, error)

GetUserEmailByID resolves the email address of the auth user identified by userID. The `users` table belongs to internal/auth's schema, but internal/ auth already imports internal/config (service_mfa.go), so internal/config cannot import internal/auth back without a cycle. This method queries the shared database directly by table/column name instead, returning a plain string so no auth.User type crosses the package boundary. Returns ("", nil) when userID does not resolve to a row -- callers must treat that as "identity unresolved," not as a legitimately blank email.

func (*PostgresStore) HasAccountCredentials

func (s *PostgresStore) HasAccountCredentials(ctx context.Context, accountID string) (bool, error)

HasAccountCredentials returns true if at least one credential exists for the account.

func (*PostgresStore) IsNotificationMuted

func (s *PostgresStore) IsNotificationMuted(ctx context.Context, recipientEmail, scope string) (bool, error)

IsNotificationMuted returns true when (email, scope) has a matching row in muted_recipients. The email lookup is case-insensitive (LOWER on insert plus LOWER($1) here).

func (*PostgresStore) LatestLadderRunStartedAt

func (s *PostgresStore) LatestLadderRunStartedAt(ctx context.Context, configID string) (*time.Time, error)

LatestLadderRunStartedAt returns the maximum started_at for the given config_id, or nil when no run has been recorded yet. Drives the per-cadence self-gate in handleLadderRun (Q6).

func (*PostgresStore) ListAccountRegistrations

func (s *PostgresStore) ListAccountRegistrations(ctx context.Context, filter AccountRegistrationFilter) ([]AccountRegistration, error)

ListAccountRegistrations returns registrations matching the filter.

func (*PostgresStore) ListAccountServiceOverrides

func (s *PostgresStore) ListAccountServiceOverrides(ctx context.Context, accountID string) ([]AccountServiceOverride, error)

ListAccountServiceOverrides returns all overrides for an account.

func (*PostgresStore) ListActiveSuppressions

func (s *PostgresStore) ListActiveSuppressions(ctx context.Context) ([]PurchaseSuppression, error)

ListActiveSuppressions returns every suppression row whose expiry hasn't passed. The scheduler groups these by the 6-tuple key (account, provider, service, region, resource_type, engine) to subtract cumulative suppressed counts from its rec-list output.

func (*PostgresStore) ListCloudAccounts

func (s *PostgresStore) ListCloudAccounts(ctx context.Context, filter CloudAccountFilter) ([]CloudAccount, error)

ListCloudAccounts returns accounts matching the filter, with credentials_configured derived.

func (*PostgresStore) ListPendingExecutionIDsForAccount

func (s *PostgresStore) ListPendingExecutionIDsForAccount(ctx context.Context, accountID string) ([]string, error)

ListPendingExecutionIDsForAccount returns the execution IDs that the frontend's Cancel-All-Then-Delete flow needs to POST cancel for. Capped at 1000 rows — a single account with more pending executions than that is an unusual operator-cleanup task rather than a button-click flow.

func (*PostgresStore) ListPurchasePlans

func (s *PostgresStore) ListPurchasePlans(ctx context.Context, filter PurchasePlanFilter) ([]PurchasePlan, error)

ListPurchasePlans lists purchase plans, optionally filtered by account IDs. When filter.AccountIDs is non-empty the result includes both plans that reference at least one of the given accounts AND legacy plans with zero plan_accounts rows (flagged with Unassigned=true). Plans that have at least one account row are returned with Unassigned=false. The no-filter case returns all plans with Unassigned=false.

func (*PostgresStore) ListServiceConfigs

func (s *PostgresStore) ListServiceConfigs(ctx context.Context) ([]ServiceConfig, error)

ListServiceConfigs lists all service configurations.

LIMIT 1000 caps the result set at three orders of magnitude above the realistic upper bound (each cloud has a bounded set of services, so the total is roughly (providers × service-types × per-service-variants), which stays under ~150 even with generous provider growth). The cap is defense-in-depth against a compromised admin inserting millions of rows and matches the sibling GetPendingExecutions limit.

func (*PostgresStore) ListStoredRecommendations

func (s *PostgresStore) ListStoredRecommendations(ctx context.Context, filter RecommendationFilter) ([]RecommendationRecord, error)

ListStoredRecommendations reads recommendations matching the filter. SQL-pushed conditions (Provider, Service, Region, AccountIDs, MinSavingsUSD) are applied in SQL so Postgres prunes the rows; the MinSavingsPct filter is applied in-process because the on-demand baseline lives inside the JSONB payload (not a native column).

func (*PostgresStore) ListStuckExecutions

func (s *PostgresStore) ListStuckExecutions(ctx context.Context, statuses []string, olderThan time.Duration) ([]PurchaseExecution, error)

ListStuckExecutions returns purchase executions whose Status is any of the supplied values and whose updated_at is older than the given duration. Used by the reaper sweep (issue #678) to find executions stuck in approved/running long enough that the synchronous executor has clearly failed without flipping the row to a terminal state.

Returns rows oldest-first (ORDER BY updated_at ASC) so the longest-stuck rows are processed first within a single sweep, capped at MaxListLimit so an unbounded backlog doesn't blow up the Lambda's memory budget. The reaper invokes the sweep periodically; a backlog larger than MaxListLimit just gets drained across successive invocations.

olderThan must be > 0; a zero/negative value would invert the WHERE clause into "updated_at < NOW() + |olderThan|" and reap fresh rows. Defense-in- depth: the caller (ParseReapAfterFromEnv) also rejects non-positive env values.

olderThan is passed as a Postgres interval (seconds) so the comparison happens server-side against NOW() — keeping the cutoff in the DB clock avoids any drift between the API process and the database.

func (*PostgresStore) LockPurchasePlanTx

func (s *PostgresStore) LockPurchasePlanTx(ctx context.Context, tx pgx.Tx, planID string) (*PurchasePlan, error)

LockPurchasePlanTx reads a purchase plan under a row lock held for the rest of tx. It is the single definition of "the per-plan ramp lock": every path that reads a plan's ramp position in order to write against it calls THIS method, so completions and creations serialize against each other rather than each racing on its own read. CompletePlanStep below is one caller; the create-planned-purchases handler is the other.

Returns (nil, nil) when the plan does not exist, which callers must handle explicitly: CompletePlanStep treats it as a benign race it cannot control, the create path turns it into a 404.

func (*PostgresStore) MarkCollectionStarted

func (s *PostgresStore) MarkCollectionStarted(ctx context.Context) (token string, ok bool, err error)

MarkCollectionStarted atomically sets last_collection_started_at = NOW() only when no in-flight collection is currently running. The WHERE clause treats a started_at older than 5 minutes as stale (the scheduler Lambda must have crashed) so a new collection can proceed rather than being permanently blocked. A fresh owner token is stamped into last_collection_owner_id alongside the timestamp so the caller that wins the race is the only one that can later clear the marker (issue #261 compare-and-clear guard; see ClearCollectionStarted).

Returns the token and true when this caller won the race (rowsAffected == 1) and should proceed with the async invoke, threading the token through so it can be passed to ClearCollectionStarted later. Returns ("", false, nil) when another collection is already in flight (rowsAffected == 0), signaling the handler to return 409 Conflict.

func (*PostgresStore) MarkPurchaseRevoked

func (s *PostgresStore) MarkPurchaseRevoked(ctx context.Context, purchaseID string, revokedAt time.Time, revokedVia, supportCaseID string, calcRefundAmount *float64, calcRefundCurrency string) error

MarkPurchaseRevoked stamps revoked_at / revoked_via / support_case_id and the refund-quote audit columns (calc_refund_amount, calc_refund_currency) on the purchase_history row identified by purchaseID. The UPDATE is a no-op when revoked_at is already non-null (idempotency guard). Returns a not-found error when zero rows are affected and revoked_at was previously NULL.

func (*PostgresStore) OccupiedRampStepsInRangeTx

func (s *PostgresStore) OccupiedRampStepsInRangeTx(ctx context.Context, tx pgx.Tx, planID string, from, to int) ([]int, error)

OccupiedRampStepsInRangeTx returns the steps between from and to (inclusive) of planID that already have a fan-out unit which bought or is still working, ascending. It runs in the caller's transaction so the answer can be acted on atomically; see the lock requirement below.

It exists so a caller about to mint executions for a range of steps can refuse to target one that is already covered. Both halves of the predicate are load-bearing and neither subsumes the other:

  • A step some account BOUGHT must not get a fresh root row. The completeness gate holds CurrentStep still while an account is outstanding, so the plan-scoped create endpoint keeps stamping CurrentStep+1, the same step. Approving that row re-fans-out across every account including the ones that already bought, under a fresh idempotency lineage whose derived tokens miss the provider-side dedupe entirely: a genuine duplicate commitment, not a no-op.
  • A step that already has a LIVE unit must not get a second one either. Two concurrent creates both find nothing bought, and each mints its own root row for the same step; approving both double-buys exactly as above. The first create's own pending row is what the second must see, and it is not "bought", so the bought half alone cannot stop it.

A step whose units all settled without buying (canceled) is NOT occupied: the operator abandoned it and rescheduling it is the intended recovery.

CALLER CONTRACT: hold LockPurchasePlanTx on planID for the same transaction before calling this and until the resulting inserts commit. Without that lock the answer is advisory -- two callers read it concurrently, both see the step free and both insert.

func (*PostgresStore) ReplaceRecommendations

func (s *PostgresStore) ReplaceRecommendations(ctx context.Context, collectedAt time.Time, recs []RecommendationRecord) error

ReplaceRecommendations wipes the recommendations table and reinserts the full snapshot inside a single transaction. Used for a force-full-resync path; the steady-state write path (see commit 6) is UpsertRecommendations. Atomic replace means concurrent readers either see the full old snapshot or the full new one — never a partial mid-replace state.

func (*PostgresStore) SaveAccountCredential

func (s *PostgresStore) SaveAccountCredential(ctx context.Context, accountID, credentialType, encryptedBlob string) error

SaveAccountCredential upserts an encrypted credential blob for an account.

func (*PostgresStore) SaveAccountServiceOverride

func (s *PostgresStore) SaveAccountServiceOverride(ctx context.Context, o *AccountServiceOverride) error

SaveAccountServiceOverride upserts an account service override.

func (*PostgresStore) SaveGlobalConfig

func (s *PostgresStore) SaveGlobalConfig(ctx context.Context, config *GlobalConfig) error

SaveGlobalConfig saves the global configuration.

func (*PostgresStore) SaveLadderRun

func (s *PostgresStore) SaveLadderRun(ctx context.Context, run *LadderRunDB) (*LadderRunDB, error)

SaveLadderRun inserts a new ladder_runs row. If run.ID is empty a fresh UUID is generated. Returns the persisted row with all DB-stamped fields populated.

func (*PostgresStore) SaveLadderRunWithTranches

func (s *PostgresStore) SaveLadderRunWithTranches(ctx context.Context, run *LadderRunDB, tranches []LadderTrancheDB) (*LadderRunDB, error)

SaveLadderRunWithTranches inserts the ladder_runs row AND its ladder_tranches rows in ONE transaction. If any tranche insert fails, the whole transaction (including the run row) is rolled back, so a run is never persisted without its tranches. This prevents a status=planned run with zero tranches, which the cadence self-gate (keyed on any run's started_at) would otherwise use to suppress the retry for the full cadence window. If run.ID is empty a fresh UUID is generated. Returns the persisted run row.

func (*PostgresStore) SaveLadderTranches

func (s *PostgresStore) SaveLadderTranches(ctx context.Context, tranches []LadderTrancheDB) error

SaveLadderTranches inserts a batch of ladder_tranches rows within a single transaction. Each tranche must carry a non-empty ID; duplicate IDs are rejected at the DB UNIQUE PRIMARY KEY constraint. An empty slice is a no-op.

func (*PostgresStore) SavePurchaseExecution

func (s *PostgresStore) SavePurchaseExecution(ctx context.Context, execution *PurchaseExecution) error

SavePurchaseExecution saves a purchase execution record.

func (*PostgresStore) SavePurchaseExecutionTx

func (s *PostgresStore) SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, execution *PurchaseExecution) error

SavePurchaseExecutionTx is the tx-accepting variant of SavePurchaseExecution. Used from handlers that need to bundle the execution insert with other writes (e.g. purchase_suppressions rows) in a single atomic transaction.

func (*PostgresStore) SavePurchaseHistory

func (s *PostgresStore) SavePurchaseHistory(ctx context.Context, record *PurchaseHistoryRecord) error

SavePurchaseHistory saves a purchase history record.

func (*PostgresStore) SaveRIExchangeRecord

func (s *PostgresStore) SaveRIExchangeRecord(ctx context.Context, record *RIExchangeRecord) error

SaveRIExchangeRecord saves an RI exchange record.

func (*PostgresStore) SaveServiceConfig

func (s *PostgresStore) SaveServiceConfig(ctx context.Context, config *ServiceConfig) error

SaveServiceConfig saves configuration for a service.

func (*PostgresStore) SetCancelledBy

func (s *PostgresStore) SetCancelledBy(ctx context.Context, executionID, cancelledBy string) error

SetCancelledBy stamps both the canceled_by and legacy cancelled_by columns for a single execution without touching any other column. This avoids the full-row overwrite that a SavePurchaseExecution follow-up would perform, eliminating the lost-update window between TransitionExecutionStatus and the attribution write (Finding #5). Writing both columns keeps this path symmetric with CancelExecutionAtomic/CancelScheduledExecutionAtomic (which write canceled_by only) so revoke-attribution keeps working unchanged if a future contract migration (#1278) drops the legacy column.

func (*PostgresStore) SetPlanAccounts

func (s *PostgresStore) SetPlanAccounts(ctx context.Context, planID string, accountIDs []string) error

SetPlanAccounts replaces the full account list for a plan atomically.

func (*PostgresStore) SetRecommendationsCollectionError

func (s *PostgresStore) SetRecommendationsCollectionError(ctx context.Context, errMsg string) error

SetRecommendationsCollectionError records the most recent collection's error message without touching last_collected_at or last_collection_started_at. Used by the scheduler when a collect fails partially or fully so the frontend banner surfaces the issue while existing cached rows stay visible.

This method must NOT clear last_collection_started_at (issue #261): it is called mid-run from persistCollection on every CollectRecommendations invocation that hits a provider error, including tokenless cron/cold-start/ background runs. Clearing the marker here, unconditionally and with no owner check, previously let a tokenless run's routine provider error wipe a concurrent owner run's in-flight marker, reopening the exact race the compare-and-clear guard exists to close. Only the deferred, token-guarded clearCollectionStartedBestEffort (which runs on both the success and failure exit paths of CollectRecommendations) may clear started_at.

func (*PostgresStore) StampOfferingClass

func (s *PostgresStore) StampOfferingClass(ctx context.Context, purchaseID, offeringClass string) error

StampOfferingClass writes the offering_class value onto a purchase_history row identified by purchase_id. Used by the marketplace-list handler to lazily persist offering_class fetched from AWS DescribeReservedInstances for rows created before migration 000087 or for externally-created Standard RIs. A no-match (row not found) is treated as a non-fatal warning by callers.

func (*PostgresStore) StampRIExchangeApprovedBy

func (s *PostgresStore) StampRIExchangeApprovedBy(ctx context.Context, id, approverEmail string) error

StampRIExchangeApprovedBy sets the approved_by column on an RI exchange row (issue #300). Called after CompleteRIExchangeWithPayment when approval came from a session-authed user. The stamping is best-effort (log + continue on failure so the exchange itself isn't rolled back just because the audit stamp failed).

func (*PostgresStore) TransitionExecutionStatus

func (s *PostgresStore) TransitionExecutionStatus(ctx context.Context, executionID string, fromStatuses []string, toStatus string, actor *string) (*PurchaseExecution, error)

TransitionExecutionStatus atomically transitions an execution from one of the allowed statuses to a new status. Returns the updated record, or an error if the execution was not found or not in an allowed status. actor is the UUID of the user performing the transition (nil for system-initiated paths); it is stamped onto transitioned_by and transitioned_at is always set to NOW().

func (*PostgresStore) TransitionLadderRunStatus

func (s *PostgresStore) TransitionLadderRunStatus(ctx context.Context, id string, fromStatuses []ladder.RunStatus, toStatus ladder.RunStatus) (*LadderRunDB, error)

TransitionLadderRunStatus atomically transitions a ladder_runs row from one of fromStatuses to toStatus via a CAS UPDATE. Returns the updated row on success, or (nil, nil) when zero rows are affected (race lost or unexpected current status). A hard DB error is returned as a non-nil error.

func (*PostgresStore) TransitionRIExchangeStatus

func (s *PostgresStore) TransitionRIExchangeStatus(ctx context.Context, id, fromStatus, toStatus string, actor *string) (*RIExchangeRecord, error)

TransitionRIExchangeStatus atomically transitions an RI exchange record status. Uses a single UPDATE...WHERE...RETURNING for atomicity, then diagnoses failure only if zero rows are returned. actor is the UUID of the user performing the transition (nil for system-initiated paths).

func (*PostgresStore) TransitionRegistrationStatus

func (s *PostgresStore) TransitionRegistrationStatus(ctx context.Context, reg *AccountRegistration, fromStatus string, actor *string) error

TransitionRegistrationStatus atomically updates a registration's workflow fields only if the current status matches fromStatus. Returns ErrRegistrationConflict when 0 rows are affected (another request already changed the status). actor is the UUID of the reviewer (nil for system-initiated transitions).

func (*PostgresStore) UpdateAccountRegistration

func (s *PostgresStore) UpdateAccountRegistration(ctx context.Context, reg *AccountRegistration) error

UpdateAccountRegistration updates the mutable workflow fields of a registration.

func (*PostgresStore) UpdateCloudAccount

func (s *PostgresStore) UpdateCloudAccount(ctx context.Context, account *CloudAccount) error

UpdateCloudAccount updates mutable fields of a cloud account.

func (*PostgresStore) UpdateGlobalConfigAtomic

func (s *PostgresStore) UpdateGlobalConfigAtomic(ctx context.Context, apply func(*GlobalConfig) error) (*GlobalConfig, error)

UpdateGlobalConfigAtomic performs a serialized read-modify-write of the global_config singleton. It opens a transaction, takes a transaction-scoped advisory lock (which serializes even the first insert, since a row-level FOR UPDATE cannot lock a not-yet-existing singleton row), reads the current config, applies the caller's in-place mutation, and upserts the result in the SAME transaction. This eliminates the lost-update race where two concurrent partial PUTs each read the same stale base and the later save silently drops the earlier change.

apply mutates the loaded config in place (e.g. json.Unmarshal the request body over it and validate); an error it returns aborts the transaction and is propagated unchanged (so callers can surface a 400 from validation while transport/DB errors surface as 500).

func (*PostgresStore) UpdatePurchaseHistoryListing

func (s *PostgresStore) UpdatePurchaseHistoryListing(ctx context.Context, purchaseID, listingID, listingState string) error

UpdatePurchaseHistoryListing stamps the marketplace listing fields onto a purchase_history row identified by its purchase_id (RI reservation ID). Called by the marketplace-list handler after CreateReservedInstancesListing succeeds (listing_id + listing_state="active") and by the status-poll path on the later closed/canceled transitions.

func (*PostgresStore) UpdatePurchasePlan

func (s *PostgresStore) UpdatePurchasePlan(ctx context.Context, plan *PurchasePlan) error

UpdatePurchasePlan updates an existing purchase plan. Delegates to UpdatePurchasePlanTx inside a single-call WithTx — keeps the public surface unchanged for callers that don't need to bundle this with other writes, while sharing the SQL with the Tx variant. UpdatedAt is stamped here (before the WithTx call) so existing tests that inspect plan.UpdatedAt without exercising the DB still see it set — see TestPostgresStore_UpdatePurchasePlan_NilDB.

func (*PostgresStore) UpdatePurchasePlanTx

func (s *PostgresStore) UpdatePurchasePlanTx(ctx context.Context, tx pgx.Tx, plan *PurchasePlan) error

UpdatePurchasePlanTx is the tx-accepting variant of UpdatePurchasePlan. Used by createPlannedPurchases so the per-row execution inserts and the plan's next_execution_date bump commit atomically — see the interface doc for the partial-failure rationale. Callers that need the auto-stamp of UpdatedAt either use UpdatePurchasePlan (which stamps before WithTx) or stamp it themselves before calling Tx.

func (*PostgresStore) UpsertLadderConfig

func (s *PostgresStore) UpsertLadderConfig(ctx context.Context, cfg *LadderConfigDB) (*LadderConfigDB, error)

UpsertLadderConfig inserts or updates the per-account ladder configuration. The upsert key is (cloud_account_id, provider). If ID is empty a new UUID is generated; existing rows retain their original id and created_at.

Validate() must be called by the API handler before this method; the store does not re-validate to avoid duplicating error messages.

func (*PostgresStore) UpsertNotificationMute

func (s *PostgresStore) UpsertNotificationMute(ctx context.Context, recipientEmail, scope, unmuteToken string) error

UpsertNotificationMute inserts or replaces the mute row for (recipientEmail, scope). ON CONFLICT updates muted_at and unmute_token so a repeated one-click opt-out resets the audit timestamp without error.

func (*PostgresStore) UpsertRIUtilizationCache

func (s *PostgresStore) UpsertRIUtilizationCache(ctx context.Context, region string, lookbackDays int, payload []byte, fetchedAt time.Time) error

UpsertRIUtilizationCache writes or overwrites the cached utilization payload for (region, lookback_days). fetchedAt should be "now" at the time of the underlying Cost Explorer call — readers compare this against their TTL to decide freshness.

func (*PostgresStore) UpsertRecommendations

func (s *PostgresStore) UpsertRecommendations(ctx context.Context, collectedAt time.Time, recs []RecommendationRecord, successfulCollects []SuccessfulCollect) error

UpsertRecommendations is the incremental write path: it upserts each row by natural key (cloud_account_id, provider, service, region, resource_type, term, payment_option) and then evicts stale rows for the set of (provider, account) pairs that successfully collected in this run. Pairs whose collection failed are NOT in successfulCollects and their stale rows stay — callers see older data with a banner rather than a blank section.

Migration 000032 broadened the natural key to include term + payment_option, so per-rec ON CONFLICT no longer collides on SQLSTATE 21000.

The eviction predicate uses (provider, account_key) IN (unnest($2, $3)) where account_key matches the generated column on the table — nil CloudAccountID maps to uuid.Nil at the Go boundary, matching the COALESCE(cloud_account_id, '00000000-...') rule the table applies on insert. This collapses ambient and registered identities consistently on both sides of the join.

func (*PostgresStore) WithTx

func (s *PostgresStore) WithTx(ctx context.Context, fn func(tx pgx.Tx) error) error

WithTx opens a pgx transaction, runs fn, and commits on success or rolls back on error. Errors from fn are returned as-is; errors from Begin / Commit / Rollback are wrapped so callers can distinguish "user-code failed" from "transport failed".

type PurchaseExecution

type PurchaseExecution struct {
	PlanID           string                 `json:"plan_id" dynamodbav:"plan_id"`
	ExecutionID      string                 `json:"execution_id" dynamodbav:"execution_id"`
	Status           string                 `json:"status" dynamodbav:"status"` // pending, notified, approved, canceled, completed, failed
	StepNumber       int                    `json:"step_number" dynamodbav:"step_number"`
	ScheduledDate    time.Time              `json:"scheduled_date" dynamodbav:"scheduled_date"`
	NotificationSent *time.Time             `json:"notification_sent,omitempty" dynamodbav:"notification_sent,omitempty"`
	ApprovalToken    string                 `json:"approval_token,omitempty" dynamodbav:"approval_token,omitempty"`
	Recommendations  []RecommendationRecord `json:"recommendations" dynamodbav:"recommendations"`
	TotalUpfrontCost float64                `json:"total_upfront_cost" dynamodbav:"total_upfront_cost"`
	EstimatedSavings float64                `json:"estimated_savings" dynamodbav:"estimated_savings"`
	CompletedAt      *time.Time             `json:"completed_at,omitempty" dynamodbav:"completed_at,omitempty"`
	Error            string                 `json:"error,omitempty" dynamodbav:"error,omitempty"`
	TTL              int64                  `json:"ttl,omitempty" dynamodbav:"ttl,omitempty"`
	CloudAccountID   *string                `json:"cloud_account_id,omitempty" dynamodbav:"cloud_account_id,omitempty"`
	// Source identifies the CUDly surface that triggered this execution
	// ("cudly-cli" or "cudly-web"). Propagated into PurchaseOptions and
	// stamped as a tag/label onto every commitment this execution buys.
	Source string `json:"source,omitempty" dynamodbav:"source,omitempty"`
	// ApprovedBy / CancelledBy carry the email of the session-authenticated
	// user who acted on this execution via the auth-gated deep-link flow
	// (frontend /purchases/{action}/:id → login-if-needed → session-authed
	// endpoint). Nil on legacy token-only approve/cancel paths — the
	// handler / History UI falls back to the notification email as the
	// accountable party in that case. Nullable TEXT in Postgres.
	ApprovedBy  *string `json:"approved_by,omitempty" dynamodbav:"approved_by,omitempty"`
	CancelledBy *string `json:"cancelled_by,omitempty" dynamodbav:"cancelled_by,omitempty"`
	// CreatedByUserID is the UUID of the session-authenticated user who
	// triggered this execution (e.g. clicked Execute on the Recommendations
	// page or submitted the bulk-purchase modal). NULL on rows created
	// before the column was introduced (migration 000041) and on
	// scheduler-driven executions where there is no human creator. Used
	// by the session-authed cancel handler to enforce cancel:own_executions
	// — a non-admin may cancel only executions they themselves created.
	// NULL is treated as "not the current user".
	CreatedByUserID *string `json:"created_by_user_id,omitempty" dynamodbav:"created_by_user_id,omitempty"`
	// RetryExecutionID points from a *failed* execution to the new
	// execution created when the user clicked Retry (issue #47). Set
	// only on the original failed row; NULL on every other row including
	// the retry itself (the retry's own RetryAttemptN > 0 is the
	// "this is a retry" marker). Forms a forward-pointing chain:
	// failed_v1.retry_execution_id = failed_v2.execution_id, etc.
	// Migration 000042 self-FKs the column ON DELETE SET NULL so a
	// cleanup of a successor doesn't cascade-delete its predecessor.
	RetryExecutionID *string `json:"retry_execution_id,omitempty" dynamodbav:"retry_execution_id,omitempty"`
	// RetryAttemptN is the position of this execution in a retry chain.
	// 0 (default) on every fresh execution; 1 on the first retry of any
	// failed row; n+1 on the n+1-th retry. The handler reads the
	// predecessor's count and stamps n+1 atomically with the new
	// INSERT inside the retry transaction. The History UI uses this to
	// soft-block retries past a threshold so an obviously-stuck
	// configuration doesn't accumulate dozens of dead retry rows.
	// Migration 000042 added the column with default 0 so legacy rows
	// look exactly like fresh first-retry candidates.
	RetryAttemptN int `json:"retry_attempt_n,omitempty" dynamodbav:"retry_attempt_n,omitempty"`
	// CapacityPercent records what fraction of the originally-recommended
	// counts the user chose when the bulk Purchase flow submitted this
	// execution (1..100). Audit-only: the Recommendations slice already
	// carries the scaled counts, so backend math is unaffected by this
	// field. Defaults to 100 for legacy and scheduler-driven executions.
	CapacityPercent int `json:"capacity_percent,omitempty" dynamodbav:"capacity_percent,omitempty"`
	// ApprovalTokenExpiresAt is the UTC deadline after which the
	// ApprovalToken must be rejected by ApproveExecution and
	// loadCancelableExecution (issue #397). Set at execution creation to
	// ScheduledDate + ApprovalTokenTTL. NULL on rows created before
	// migration 000051 — legacy rows are treated as not-yet-expired
	// (backward-compatible: the TTL-checking gate only fires when the
	// field is non-nil). Migration 000051 adds the column; new rows
	// always carry a non-nil value.
	ApprovalTokenExpiresAt *time.Time `json:"approval_token_expires_at,omitempty" dynamodbav:"approval_token_expires_at,omitempty"`
	// ExecutedByUserID is the UUID of the session user who triggered a
	// direct-execute (issue #289, execute-any/execute-own). NULL on rows
	// that went through the normal approval flow. Non-null signals the
	// approval step was intentionally skipped by an authorized operator.
	// Migration 000058 adds the column.
	ExecutedByUserID *string `json:"executed_by_user_id,omitempty" dynamodbav:"executed_by_user_id,omitempty"`
	// ExecutedAt is the UTC timestamp when the direct-execute path fired.
	// NULL for rows on the normal approval flow. Migration 000058.
	ExecutedAt *time.Time `json:"executed_at,omitempty" dynamodbav:"executed_at,omitempty"`
	// PreApprovalSkipReason is a human-readable token describing why the
	// approval step was skipped. For direct-execute rows it is the literal
	// string "direct-execute permission". NULL on every normal-flow row.
	// Migration 000058.
	PreApprovalSkipReason *string `json:"pre_approval_skip_reason,omitempty" dynamodbav:"pre_approval_skip_reason,omitempty"`
	// IdempotencyKey is the stable lineage anchor the per-rec provider
	// idempotency token is derived from (issue #1012). Unlike ExecutionID
	// it is NOT regenerated on Retry or multi-account fan-out: it is
	// generated once at first creation, copied verbatim onto every Retry
	// successor, and combined with the account ID to seed each per-account
	// fan-out row. This makes DeriveIdempotencyToken reproduce the same
	// token across a strand-and-re-drive so the provider dedupes and the
	// commitment is never bought twice. Empty on rows created before
	// migration 000066 — the derivation falls back to ExecutionID for those
	// (identical to the pre-fix behavior for a single un-retried execution).
	IdempotencyKey string `json:"idempotency_key,omitempty" dynamodbav:"idempotency_key,omitempty"`
	// ScheduledExecutionAt is set by the Gmail-style pre-fire delay path
	// (issue #291 wave-2) when an approve defers the cloud SDK call. The
	// scheduler fires the actual SDK call when this timestamp is in the past.
	// NULL on every immediate-execute row. Migration 000065.
	ScheduledExecutionAt *time.Time `json:"scheduled_execution_at,omitempty" dynamodbav:"scheduled_execution_at,omitempty"`
}

PurchaseExecution represents a single execution of a purchase plan.

func (*PurchaseExecution) IsCancelable

func (e *PurchaseExecution) IsCancelable() bool

IsCancelable reports whether an execution may still be canceled. Only the pre-purchase states ("pending"/"notified"/"scheduled") qualify: once a row reaches "approved" or "running" the AWS commitment is being or has been created, so canceling would leave the DB and the cloud out of sync; "canceled", "completed", "failed", "expired", and "paused" are likewise non-cancelable. The "scheduled" state is cancellable because the cloud SDK has not been called yet (issue #291 wave-2). Both cancel paths (purchase.Manager.CancelExecution on the email-token flow and the session-authed cancelPurchaseViaSession) call this single predicate so the policy can never drift between them (issue #645).

type PurchaseHistoryFilter

type PurchaseHistoryFilter struct {
	// Provider matches purchase_history.provider exactly. Empty skips the clause.
	Provider string
	// AccountIDs matches purchase_history.cloud_account_id (the cloud_accounts
	// UUID FK) with ANY($). Empty/nil skips this half of the account predicate.
	AccountIDs []string
	// ExternalIDsByProvider matches purchase_history.account_id (the
	// cloud-provider external account number) scoped per provider. The caller
	// resolves AccountIDs to their (provider, external_id) pairs and groups the
	// external ids by provider, so the predicate matches each external id only
	// against rows of its own provider:
	//
	//	(provider = $p AND account_id = ANY($extsForP))
	//
	// This keeps the (provider, external_id) pairing intact so a filter for
	// aws/123 never pulls azure/123 rows that reuse the same external number.
	// The "" provider key means "provider unknown" (legacy raw external number)
	// and matches account_id without a provider gate. Empty/nil skips this half
	// of the account predicate.
	ExternalIDsByProvider map[string][]string
	// Start/End bound purchase_history.timestamp. nil for both skips the clause;
	// nil for either leaves that side open (caller owns any range cap, see
	// api.MaxHistoryDateRangeDays).
	Start *time.Time
	End   *time.Time
	// Limit caps the row count; clamped to [1, MaxListLimit] with a
	// DefaultListLimit fallback when <= 0.
	Limit int
}

PurchaseHistoryFilter is the filter set consumed by StoreInterface.GetPurchaseHistoryFiltered. Each field is optional; a zero-valued filter selects all rows (same plan-shape as GetAllPurchaseHistory). See the implementation docstring for the per-field semantics and the dual-column account predicate.

type PurchaseHistoryRecord

type PurchaseHistoryRecord struct {
	AccountID    string    `json:"account_id" dynamodbav:"account_id"`
	PurchaseID   string    `json:"purchase_id" dynamodbav:"purchase_id"`
	Timestamp    time.Time `json:"timestamp" dynamodbav:"timestamp"`
	Provider     string    `json:"provider" dynamodbav:"provider"`
	Service      string    `json:"service" dynamodbav:"service"`
	Region       string    `json:"region" dynamodbav:"region"`
	ResourceType string    `json:"resource_type" dynamodbav:"resource_type"`
	Count        int       `json:"count" dynamodbav:"count"`
	Term         int       `json:"term" dynamodbav:"term"`
	Payment      string    `json:"payment" dynamodbav:"payment"`
	UpfrontCost  float64   `json:"upfront_cost" dynamodbav:"upfront_cost"`
	// MonthlyCost is nil when the provider API did not return a monthly
	// recurring breakdown for this commitment (e.g. Azure all-upfront where
	// no recurring charge exists at the commitment layer). GCP commitments
	// are monthly-billed in this repo, so they always populate MonthlyCost.
	// The frontend renders "—" for nil, "$X.XX" when populated. Aggregations
	// must skip nil entries rather than treating them as $0 to avoid
	// distorting totals. Migration 000063 dropped the NOT NULL constraint so
	// new rows can carry NULL; existing rows with 0.0 are preserved as-is
	// (those are real zeros from AWS all-upfront commitments).
	MonthlyCost      *float64 `json:"monthly_cost" dynamodbav:"monthly_cost"`
	EstimatedSavings float64  `json:"estimated_savings" dynamodbav:"estimated_savings"`
	PlanID           string   `json:"plan_id,omitempty" dynamodbav:"plan_id,omitempty"`
	PlanName         string   `json:"plan_name,omitempty" dynamodbav:"plan_name,omitempty"`
	RampStep         int      `json:"ramp_step,omitempty" dynamodbav:"ramp_step,omitempty"`
	CloudAccountID   *string  `json:"cloud_account_id,omitempty" dynamodbav:"cloud_account_id,omitempty"`
	Status           string   `json:"status,omitempty" dynamodbav:"-"`
	// Approver holds the email address the approval request was sent to (or
	// would have been, if SES failed). Set only on pending rows, so the
	// History UI can show "awaiting approval from <addr>" and the user knows
	// exactly whose inbox to check. Excluded from DB persistence.
	Approver string `json:"approver,omitempty" dynamodbav:"-"`
	Source   string `json:"source,omitempty" dynamodbav:"source,omitempty"`
	// StatusDescription carries a short human-readable explanation for non-
	// completed rows. For "failed", this is the stored Error message (e.g.
	// "send failed: Missing domain"). For "expired", a canned reminder that
	// the 7-day approval window elapsed. Empty on completed/pending rows —
	// those speak for themselves via Status alone.
	StatusDescription string `json:"status_description,omitempty" dynamodbav:"-"`
	// CreatedByUserID propagates the originating execution's
	// created_by_user_id so the History UI can decide whether to render
	// the inline Cancel button (issue #46): a non-admin user only sees
	// the button on their own pending rows. Set only for synthesized
	// pending/notified rows (executions); empty on completed history
	// rows (where the action has already completed). Excluded from DB
	// persistence.
	CreatedByUserID string `json:"created_by_user_id,omitempty" dynamodbav:"-"`
	// RetryExecutionID propagates the originating execution's pointer to
	// its successor when the user retried it (issue #47). Set only on
	// the *original* failed row that has been retried; the History UI
	// renders an inline "Retried as #abc" link to the successor row when
	// this is non-empty. Excluded from DB persistence (synthesized from
	// purchase_executions).
	RetryExecutionID string `json:"retry_execution_id,omitempty" dynamodbav:"-"`
	// RetryAttemptN propagates the originating execution's retry-chain
	// position so the History UI can render "↻ Retry of #xyz" inline
	// links on retry rows (n > 0) and gate the Retry button against the
	// soft-block threshold (n >= 5). Excluded from DB persistence.
	RetryAttemptN int `json:"retry_attempt_n,omitempty" dynamodbav:"-"`
	// OpsHint is a short operator-actionable message rendered inline in
	// place of the Retry button when the failure reason on the row
	// matches a known-persistent-misconfiguration pattern (e.g.
	// "FROM_EMAIL not configured" → "Set FROM_EMAIL tfvar then retry").
	// Set only on `failed` rows whose Error matches the persistent map;
	// empty otherwise. Excluded from DB persistence (computed at read
	// time so updates to the persistent-failure map land instantly).
	OpsHint string `json:"ops_hint,omitempty" dynamodbav:"-"`
	// IsAuditGap marks a synthesized "completed" row whose purchase_history
	// write failed after a successful purchase (issue #621). Such a row is
	// reconstructed from the execution so the purchase stays visible, but its
	// execution-level dollars are excluded from the committed totals: a
	// partially-saved multi-rec execution can have BOTH some real
	// purchase_history rows AND this synthesized row, so adding the full
	// execution total would double-count the recs that did save. The dollars
	// are surfaced via the individual purchase_history rows that succeeded;
	// this row is the audit flag, not a money source. Real purchase_history
	// rows loaded from the DB always leave this false. Excluded from DB
	// persistence (set only at read time on synthesized rows).
	IsAuditGap bool `json:"is_audit_gap,omitempty" dynamodbav:"-"`
	// CreatedByUserEmail is the email address of the user who created the
	// underlying execution, resolved from CreatedByUserID via the auth
	// service. Populated only on synthesized execution rows (pending,
	// notified, failed, expired, canceled) when a valid user ID is
	// present; empty for scheduler-driven executions, legacy NULL-creator
	// rows, and completed purchase_history rows. Excluded from DB
	// persistence (resolved at read time). The UI renders this in the
	// Approval Queue "Created by" column instead of the raw UUID.
	CreatedByUserEmail string `json:"created_by_user_email,omitempty" dynamodbav:"-"`

	// --- Revocation window fields (issue #290) ---
	//
	// RevocationWindowClosesAt is set when the purchase_history row is
	// written: Timestamp + the provider-specific free-cancel window
	// (Azure: 7 days). NULL for AWS EC2 (no direct cancel API) and GCP
	// (no free-cancel window). Persisted in purchase_history.
	RevocationWindowClosesAt *time.Time `json:"revocation_window_closes_at,omitempty" dynamodbav:"revocation_window_closes_at,omitempty"`
	// RevokedAt is set by the revoke endpoint when the provider API
	// confirmed the cancellation / refund. Persisted.
	RevokedAt *time.Time `json:"revoked_at,omitempty" dynamodbav:"revoked_at,omitempty"`
	// RevokedVia identifies how the revocation was completed: "direct-api"
	// (provider returned 2xx) or "support-case" (AWS Support case filed).
	// Persisted.
	RevokedVia string `json:"revoked_via,omitempty" dynamodbav:"revoked_via,omitempty"`
	// SupportCaseID is non-empty when RevokedVia == "support-case".
	// Persisted.
	SupportCaseID string `json:"support_case_id,omitempty" dynamodbav:"support_case_id,omitempty"`

	// --- Refund-quote audit fields (issue #290 Finding #4, migration 000071) ---
	//
	// CalcRefundAmount is the amount Azure quoted at CalculateRefund time, captured
	// for audit and TOCTOU-divergence detection in the two-step revoke confirm flow.
	// NULL for revocations that predate this feature or where Azure returned no amount.
	CalcRefundAmount *float64 `json:"calc_refund_amount,omitempty" dynamodbav:"calc_refund_amount,omitempty"`
	// CalcRefundCurrency is the ISO-4217 currency code from the CalculateRefund quote
	// (e.g. "USD"). NULL when CalcRefundAmount is NULL.
	CalcRefundCurrency string `json:"calc_refund_currency,omitempty" dynamodbav:"calc_refund_currency,omitempty"`

	// --- Partial-success reconciliation (issue #290 Finding #6, migration 000072) ---
	//
	// RevocationInFlight is set to true immediately before the Azure Return API call
	// and cleared (set to false) by a successful MarkPurchaseRevoked. When all DB
	// retries fail, the flag stays true so the finalize_revocations scheduled sweep
	// can detect and retry the MarkPurchaseRevoked write without re-calling Azure
	// (preventing a duplicate-refund error).
	RevocationInFlight bool `json:"revocation_in_flight,omitempty" dynamodbav:"revocation_in_flight,omitempty"`

	// OfferingClass records whether this commitment is a 'standard' or
	// 'convertible' RI. NULL on pre-migration rows. The Sell-on-Marketplace
	// button renders only when this equals "standard" (issue #292).
	// Persisted in purchase_history via migration 000087.
	OfferingClass string `json:"offering_class,omitempty" dynamodbav:"offering_class,omitempty"`
	// ListingID is the AWS ReservedInstancesListingId returned by
	// CreateReservedInstancesListing. Empty when the RI has not been
	// listed. Persisted in purchase_history via migration 000087.
	ListingID string `json:"listing_id,omitempty" dynamodbav:"listing_id,omitempty"`
	// ListingState mirrors the AWS marketplace listing state (see the
	// ListingState* constants). Empty when not listed. Persisted in
	// purchase_history via migration 000087.
	ListingState string `json:"listing_state,omitempty" dynamodbav:"listing_state,omitempty"`
}

PurchaseHistoryRecord is the response-layer representation for rows on the /api/history page. DB-backed rows always describe *completed* purchases; the handler additionally synthesizes rows for pending executions so users can see (and cancel) in-flight approvals. Status is the discriminator — the DB layer never writes it (tag `dynamodbav:"-"` keeps it out of persistence), and the API layer populates it as "completed" or "pending" before returning.

type PurchasePlan

type PurchasePlan struct {
	ID                     string                   `json:"id" dynamodbav:"id"`
	Name                   string                   `json:"name" dynamodbav:"name"`
	Enabled                bool                     `json:"enabled" dynamodbav:"enabled"`
	AutoPurchase           bool                     `json:"auto_purchase" dynamodbav:"auto_purchase"`
	NotificationDaysBefore int                      `json:"notification_days_before" dynamodbav:"notification_days_before"`
	Services               map[string]ServiceConfig `json:"services" dynamodbav:"services"`
	RampSchedule           RampSchedule             `json:"ramp_schedule" dynamodbav:"ramp_schedule"`
	CreatedAt              time.Time                `json:"created_at" dynamodbav:"created_at"`
	UpdatedAt              time.Time                `json:"updated_at" dynamodbav:"updated_at"`
	NextExecutionDate      *time.Time               `json:"next_execution_date,omitempty" dynamodbav:"next_execution_date,omitempty"`
	LastExecutionDate      *time.Time               `json:"last_execution_date,omitempty" dynamodbav:"last_execution_date,omitempty"`
	LastNotificationSent   *time.Time               `json:"last_notification_sent,omitempty" dynamodbav:"last_notification_sent,omitempty"`
	// Unassigned is true when the plan has zero rows in plan_accounts.
	// This can happen for legacy plans created before target_accounts was
	// required (issue #743). Such plans are invisible when an account filter
	// is active because the normal JOIN excludes them; ListPurchasePlans
	// surfaces them alongside filtered results so operators can find and
	// re-scope them. The field is omitted (false) in the no-filter case
	// where all plans are returned unconditionally.
	Unassigned bool `json:"unassigned,omitempty" dynamodbav:"unassigned,omitempty"`
}

PurchasePlan represents a saved purchase plan for automated execution.

func (*PurchasePlan) Validate

func (p *PurchasePlan) Validate() error

Validate validates the PurchasePlan.

type PurchasePlanFilter

type PurchasePlanFilter struct {
	AccountIDs []string // nil/empty = all plans
}

PurchasePlanFilter parameterises ListPurchasePlans. Zero-value means "no filter" (all plans are returned). Non-empty AccountIDs restricts the result to plans that reference at least one of the given account IDs via the plan_accounts join table.

type PurchaseSuppression

type PurchaseSuppression struct {
	ID              string    `json:"id"`
	ExecutionID     string    `json:"execution_id"`
	AccountID       string    `json:"account_id"`
	Provider        string    `json:"provider"`
	Service         string    `json:"service"`
	Region          string    `json:"region"`
	ResourceType    string    `json:"resource_type"`
	Engine          string    `json:"engine"`
	SuppressedCount int       `json:"suppressed_count"`
	ExpiresAt       time.Time `json:"expires_at"`
	CreatedAt       time.Time `json:"created_at"`
}

PurchaseSuppression records the per-tuple grace window after a bulk purchase. See migration 000037 for the full SQL shape + lifecycle documentation. Written inside the same transaction as the execution insert; deleted inside the same transaction as a cancel/expire status transition.

type RIExchangeRecord

type RIExchangeRecord struct {
	ID                 string   `json:"id"`
	AccountID          string   `json:"account_id"`
	ExchangeID         string   `json:"exchange_id"`
	Region             string   `json:"region"`
	SourceRIIDs        []string `json:"source_ri_ids"`
	SourceInstanceType string   `json:"source_instance_type"`
	SourceCount        int      `json:"source_count"`
	TargetOfferingID   string   `json:"target_offering_id"`
	TargetInstanceType string   `json:"target_instance_type"`
	TargetCount        int      `json:"target_count"`
	PaymentDue         string   `json:"payment_due"`
	Status             string   `json:"status"`
	ApprovalToken      string   `json:"approval_token,omitempty"`
	Error              string   `json:"error,omitempty"`
	Mode               string   `json:"mode"`
	// CreatedByUserID is the UUID of the session user who submitted the exchange
	// (populated for dashboard-initiated exchanges; nil for automated or legacy
	// email-link-initiated ones). Exposed to the frontend so the Approve button
	// can apply the approve-own ownership check client-side.
	CreatedByUserID *string `json:"created_by_user_id,omitempty"`
	// ApprovedBy carries the email of the session user who approved the exchange
	// via the dashboard Approve button (issue #300). Nil for token-authed approvals.
	ApprovedBy *string `json:"approved_by,omitempty"`
	// LadderRunID links this exchange record to the ladder run that created it
	// (cudly-ladder engine). Nil for standalone ri_exchange_reshape task records.
	// The database column ri_exchange_history.ladder_run_id was added in migration
	// 000080 and is the authoritative source for origin scoping in
	// CancelPendingExchangesByOrigin.
	//
	// KNOWN/ACCEPTABLE: the FK is ON DELETE SET NULL (migration 000080), so
	// deleting a ladder_runs row nulls this column and reclassifies the record
	// as standalone. A still-pending reshape then becomes standalone-cancellable
	// (the standalone-origin sweep would cancel it). This is acceptable: a
	// deleted run has no owner to approve its pendings, so canceling them on the
	// next standalone sweep is the safe outcome, not a leak.
	LadderRunID    *string    `json:"ladder_run_id,omitempty"`
	CreatedAt      time.Time  `json:"created_at"`
	UpdatedAt      time.Time  `json:"updated_at"`
	CompletedAt    *time.Time `json:"completed_at,omitempty"`
	ExpiresAt      *time.Time `json:"expires_at,omitempty"`
	CloudAccountID *string    `json:"cloud_account_id,omitempty"`
}

RIExchangeRecord represents a record in the ri_exchange_history table.

type RIUtilizationCacheEntry

type RIUtilizationCacheEntry struct {
	Region       string
	LookbackDays int
	Payload      []byte
	FetchedAt    time.Time
}

RIUtilizationCacheEntry is a single cached Cost Explorer GetReservationUtilization result keyed by (region, lookback_days). Payload is the JSON encoding of the caller's utilization slice — kept opaque here so the config package stays free of AWS-provider types. TTL freshness is evaluated in the caller (api-layer cache wrapper) based on FetchedAt vs. a caller-supplied TTL.

type RampSchedule

type RampSchedule struct {
	Type             string    `json:"type" dynamodbav:"type"` // immediate, weekly, monthly, custom
	PercentPerStep   float64   `json:"percent_per_step" dynamodbav:"percent_per_step"`
	StepIntervalDays int       `json:"step_interval_days" dynamodbav:"step_interval_days"`
	CurrentStep      int       `json:"current_step" dynamodbav:"current_step"`
	TotalSteps       int       `json:"total_steps" dynamodbav:"total_steps"`
	StartDate        time.Time `json:"start_date" dynamodbav:"start_date"`
}

RampSchedule defines how purchases are spread over time.

func (*RampSchedule) GetCurrentCoverage

func (r *RampSchedule) GetCurrentCoverage(baseCoverage float64) float64

GetCurrentCoverage calculates the current effective coverage based on ramp progress.

func (*RampSchedule) GetNextPurchaseDate

func (r *RampSchedule) GetNextPurchaseDate() time.Time

GetNextPurchaseDate calculates when the next purchase step should occur.

func (*RampSchedule) IsComplete

func (r *RampSchedule) IsComplete() bool

IsComplete returns true if all ramp steps are done.

func (*RampSchedule) Validate

func (r *RampSchedule) Validate() error

Validate validates the RampSchedule.

type RampStepBlock

type RampStepBlock struct {
	StepNumber      int
	StuckExecutions int
}

RampStepBlock reports a plan's next ramp step and how many of that step's executions are stuck on it. Returned by GetStuckRampSteps and rendered by the plan-health ramp_blocked factor (issue #1861).

StuckExecutions counts executions rather than accounts because that is what the query can prove: a fanned-out step writes one row per cloud account, so the two coincide for the multi-account plans the factor exists for, but a step that never fanned out (an approval that expired before it ran) has one row standing for the whole step. Naming the count for the rows keeps the tooltip from asserting an account total it did not measure.

type RecommendationFilter

type RecommendationFilter struct {
	Provider      string   // "aws" / "azure" / "gcp" / "" (all)
	Service       string   // "" = all services
	Region        string   // "" = all regions
	AccountIDs    []string // nil/empty = all accounts
	MinSavingsUSD float64  // 0 = no floor on monthly savings dollar amount
	MinSavingsPct float64  // 0 = no floor on savings percentage (0–100 scale)
	ID            string   // "" = all ids; non-empty = exact match on the id column
}

RecommendationFilter parameterises ListStoredRecommendations and the handler-facing scheduler.ListRecommendations wrapper. Zero-value fields mean "no filter"; non-empty AccountIDs restricts to the given IDs.

MinSavingsUSD is a dollar floor: only recommendations whose monthly savings are >= MinSavingsUSD are returned. 0 means no floor.

MinSavingsPct is a percentage floor (0–100): only recommendations whose effective savings percentage (savings/on-demand*100) meets or exceeds this threshold are returned. 0 means no floor. Applied in-process after the DB query rather than in SQL (avoids a computed column). These two filters are independent and can be combined.

type RecommendationRecord

type RecommendationRecord struct {
	ID           string `json:"id" dynamodbav:"id"`
	Provider     string `json:"provider" dynamodbav:"provider"`
	Service      string `json:"service" dynamodbav:"service"`
	Region       string `json:"region" dynamodbav:"region"`
	ResourceType string `json:"resource_type" dynamodbav:"resource_type"`
	Engine       string `json:"engine,omitempty" dynamodbav:"engine,omitempty"`
	// Details preserves the full common.ServiceDetails payload from the
	// source common.Recommendation so the purchase path can reconstruct
	// the correct typed *Details pointer at execute time (issue #453).
	// Stored as raw JSON because RecommendationRecord lives in the
	// config package, which must NOT import pkg/common (the dependency
	// graph is config <- common in callers, never the reverse). The
	// scheduler populates this at collection time via
	// common.MarshalServiceDetails; the purchase manager reads it via
	// common.DecodeServiceDetailsFor when it builds the
	// common.Recommendation handed to the cloud service client.
	//
	// Empty for rows persisted before #453 — DecodeServiceDetailsFor
	// returns a zero-valued typed pointer in that case so the cloud
	// client's findOfferingID type-assertion still succeeds (the
	// service-side buildOfferingFilters substitutes default
	// Platform / Tenancy / Scope / AZConfig values). New rows always
	// carry the full Details, so non-default platforms (Windows EC2,
	// Postgres RDS, etc.) round-trip correctly.
	Details json.RawMessage `json:"details,omitempty" dynamodbav:"-"`
	Count   int             `json:"count" dynamodbav:"count"`
	// RecommendedCount is the pre-scaling count the collector originally
	// recommended, before the bulk-purchase Capacity % slider scaled it down.
	// The web execute path stamps it so the backend can verify the
	// client-supplied capacity_percent against the scaled Count
	// (floor(RecommendedCount*pct/100) must equal Count) rather than trusting
	// a decorative audit field that could silently disagree (#647). Optional:
	// 0 / absent means "not supplied" (legacy callers, scheduler/CLI rows,
	// retry replays) and the consistency check is skipped for that rec.
	RecommendedCount int     `json:"recommended_count,omitempty" dynamodbav:"recommended_count,omitempty"`
	Term             int     `json:"term" dynamodbav:"term"`
	Payment          string  `json:"payment" dynamodbav:"payment"`
	UpfrontCost      float64 `json:"upfront_cost" dynamodbav:"upfront_cost"`
	// MonthlyCost is nil when the provider API did not return a monthly
	// recurring breakdown (rendered as "—" in the UI, not "$0").
	// Backward-compatible with DynamoDB: existing items with a numeric 0
	// attribute unmarshal as a pointer to 0.0; absent attributes unmarshal
	// as nil. No migration needed.
	MonthlyCost *float64 `json:"monthly_cost" dynamodbav:"monthly_cost"`
	Savings     float64  `json:"savings" dynamodbav:"savings"`
	// OnDemandCost is the canonical on-demand monthly baseline for the
	// recommended commitment, sourced directly from the cloud provider
	// (Azure `CostWithNoReservedInstances`, AWS Cost Explorer
	// `EstimatedMonthlyOnDemandCost`). Persisted via the recommendations
	// row's JSONB `payload` column — no DDL needed.
	//
	// nil means the provider API did not return a baseline; the frontend
	// falls back to reconstructing on-demand from `monthly_cost + savings
	// + amortized_upfront`. When non-nil, the frontend prefers the raw
	// value over reconstruction so anomalies in the reconstructed
	// denominator (e.g. Azure all-upfront recs where monthly_cost=$0
	// collapses the denominator) don't inflate the displayed effective
	// savings %. See #274.
	OnDemandCost *float64 `json:"on_demand_cost,omitempty" dynamodbav:"on_demand_cost,omitempty"`
	// SavingsPercentage is the provider-authoritative effective savings %
	// reported directly by the cloud provider (AWS Cost Explorer
	// `EstimatedMonthlySavingsPercentage`, Azure / GCP converters' computed
	// SavingsPercentage). It is the same figure the CLI/reporter prints
	// verbatim (internal/reporter/reporter.go); persisting it lets the GUI
	// show the identical number instead of re-deriving it client-side from
	// savings / on-demand. Persisted via the recommendations row's JSONB
	// `payload` column; no DDL needed.
	//
	// nil means the provider did not report a percentage; the frontend then
	// falls back to the client-side reconstruction (effectiveSavingsPct).
	// When non-nil, the frontend prefers this value so the displayed % cannot
	// drift from the provider's authoritative number and AWS recs missing
	// on_demand_cost still render a real % rather than an em-dash (see #323).
	SavingsPercentage *float64 `json:"savings_percentage" dynamodbav:"savings_percentage,omitempty"`
	Selected          bool     `json:"selected" dynamodbav:"selected"`
	Purchased         bool     `json:"purchased" dynamodbav:"purchased"`
	PurchaseID        string   `json:"purchase_id,omitempty" dynamodbav:"purchase_id,omitempty"`
	Error             string   `json:"error,omitempty" dynamodbav:"error,omitempty"`
	CloudAccountID    *string  `json:"cloud_account_id,omitempty" dynamodbav:"cloud_account_id,omitempty"`
	// SuppressedCount is the cumulative count already committed against
	// this recommendation's 6-tuple (account, provider, service, region,
	// resource_type, engine) within the active grace window. The
	// scheduler subtracts this from Count before returning the rec to
	// the frontend; a rec where SuppressedCount ≥ original count is
	// dropped entirely. Populated by the scheduler — zero on writes.
	SuppressedCount int `json:"suppressed_count,omitempty" dynamodbav:"suppressed_count,omitempty"`
	// SuppressionExpiresAt is the earliest expiry across all active
	// suppression rows contributing to this tuple. The frontend uses it
	// to render "Xd remaining" on the recently-purchased badge.
	SuppressionExpiresAt *time.Time `json:"suppression_expires_at,omitempty" dynamodbav:"suppression_expires_at,omitempty"`
	// PrimarySuppressionExecutionID identifies the execution whose
	// suppression contributed the most to this tuple (ties broken by
	// newest created_at). The frontend badge deep-links to Purchase
	// History filtered to this execution.
	PrimarySuppressionExecutionID *string `json:"primary_suppression_execution_id,omitempty" dynamodbav:"primary_suppression_execution_id,omitempty"`
	// UsageHistory is a short time-series of daily RI-coverage percentages
	// (0-100) for the last N days of the lookback window, ordered from
	// oldest to newest. nil means the collector did not populate it (e.g.
	// provider not yet wired); an empty non-nil slice means the collector
	// ran but returned no daily data. The frontend renders nil as "—" and
	// a non-empty slice as a thumbnail sparkline. Stored inside the
	// recommendations JSONB payload — no DDL change needed (closes #239
	// Part 1 for AWS).
	UsageHistory []float64 `json:"usage_history,omitempty" dynamodbav:"usage_history,omitempty"`
	// VCPU and MemoryGB surface the compute size of the recommended
	// instance type so the frontend's Capacity column can render
	// "<vcpu> vCPU / <memory> GB" without parsing the opaque Details blob
	// (#219). They are NOT persisted: the canonical source is the typed
	// ComputeDetails nested inside Details (config must stay free of
	// pkg/common imports). The api layer decodes Details via
	// common.DecodeServiceDetailsFor in buildRecommendationsResponse and
	// stamps these top-level fields on the way out, so the API JSON carries
	// them at the top level where the frontend already reads them.
	//
	// Pointers (not plain int/float64) so "absent / non-compute / unknown
	// size" serializes as omitted rather than a misleading 0: the frontend
	// renders absent as "—", and a literal 0 would otherwise look like a
	// real "0 vCPU / 0 GB" capacity. dynamodbav:"-" because they are
	// derived-on-read, never stored.
	VCPU     *int     `json:"vcpu,omitempty" dynamodbav:"-"`
	MemoryGB *float64 `json:"memory_gb,omitempty" dynamodbav:"-"`
}

RecommendationRecord stores a recommendation with purchase status.

type RecommendationsFreshness

type RecommendationsFreshness struct {
	LastCollectedAt         *time.Time `json:"last_collected_at"`
	LastCollectionError     *string    `json:"last_collection_error"`
	LastCollectionStartedAt *time.Time `json:"last_collection_started_at"`
}

RecommendationsFreshness describes the cache staleness state surfaced to the frontend. LastCollectedAt is nil on a cold start. LastCollectionError is non-nil when the most recent collect attempt partially or fully failed. LastCollectionStartedAt is non-nil while an async collect is in flight; the scheduler clears it on completion (success or failure). A value older than 5 minutes means the scheduler crashed mid-run — the refresh handler treats it as stale and allows a new collection.

type ServiceConfig

type ServiceConfig struct {
	Provider       string   `json:"provider" dynamodbav:"provider"`
	Service        string   `json:"service" dynamodbav:"service"`
	Enabled        bool     `json:"enabled" dynamodbav:"enabled"`
	Term           int      `json:"term" dynamodbav:"term"`
	Payment        string   `json:"payment" dynamodbav:"payment"`
	Coverage       float64  `json:"coverage" dynamodbav:"coverage"`
	RampSchedule   string   `json:"ramp_schedule" dynamodbav:"ramp_schedule"`
	IncludeEngines []string `json:"include_engines,omitempty" dynamodbav:"include_engines,omitempty"`
	ExcludeEngines []string `json:"exclude_engines,omitempty" dynamodbav:"exclude_engines,omitempty"`
	IncludeRegions []string `json:"include_regions,omitempty" dynamodbav:"include_regions,omitempty"`
	ExcludeRegions []string `json:"exclude_regions,omitempty" dynamodbav:"exclude_regions,omitempty"`
	IncludeTypes   []string `json:"include_types,omitempty" dynamodbav:"include_types,omitempty"`
	ExcludeTypes   []string `json:"exclude_types,omitempty" dynamodbav:"exclude_types,omitempty"`
	// MinCount is the GUI/persisted equivalent of the CLI --min-count flag:
	// the minimum instance/node count a recommendation must carry to be
	// surfaced. Applied at read time by
	// scheduler.filterRecsByResolvedConfigs against the persisted
	// RecommendationRecord.Count. 0 (the default) disables the filter,
	// matching the CLI flag's 0-no-floor semantics.
	MinCount int `json:"min_count,omitempty" dynamodbav:"min_count,omitempty"`
}

ServiceConfig represents per-service configuration.

func ResolveServiceConfig

func ResolveServiceConfig(provider, service string, global *ServiceConfig, override *AccountServiceOverride) *ServiceConfig

ResolveServiceConfig merges a sparse per-account override on top of a global ServiceConfig. Any non-nil pointer field in override replaces the global value; nil fields inherit from global. Slice fields (include/exclude lists) are replaced wholesale when non-empty in the override.

If override is nil the global is returned unchanged (no copy is made). If global is nil but override is non-nil, a baseline ServiceConfig is synthesized with safe defaults (Enabled: true, Provider/Service from the override context via the provider and service parameters) and the override is merged into it. This lets a per-account override take effect even when no global ServiceConfig row exists for that (provider, service) pair. If both are nil, nil is returned. When merging, slice fields from the global are copied to avoid callers mutating the global's underlying arrays through the resolved config.

func (*ServiceConfig) Validate

func (c *ServiceConfig) Validate() error

Validate validates the ServiceConfig.

type StoreInterface

type StoreInterface interface {
	// Global configuration
	GetGlobalConfig(ctx context.Context) (*GlobalConfig, error)
	SaveGlobalConfig(ctx context.Context, config *GlobalConfig) error
	// UpdateGlobalConfigAtomic serializes a read-modify-write of the
	// global_config singleton under an advisory-locked transaction so
	// concurrent partial PUTs cannot lose each other's updates. apply mutates
	// the loaded config in place; its error aborts the write and is propagated.
	UpdateGlobalConfigAtomic(ctx context.Context, apply func(*GlobalConfig) error) (*GlobalConfig, error)

	// Service configuration
	GetServiceConfig(ctx context.Context, provider, service string) (*ServiceConfig, error)
	SaveServiceConfig(ctx context.Context, config *ServiceConfig) error
	ListServiceConfigs(ctx context.Context) ([]ServiceConfig, error)

	// Purchase plans
	CreatePurchasePlan(ctx context.Context, plan *PurchasePlan) error
	GetPurchasePlan(ctx context.Context, planID string) (*PurchasePlan, error)
	UpdatePurchasePlan(ctx context.Context, plan *PurchasePlan) error
	// CompletePlanStep records that ramp step stepNumber finished and advances
	// the plan's schedule to it, inside a SELECT FOR UPDATE transaction that
	// prevents the concurrent-write lost-update race of issue #1071.
	//
	// It is idempotent in stepNumber: completing a step the plan has already
	// counted writes nothing. Which sentinel says so depends on where the ramp
	// sits relative to the completing step, and callers branch on the
	// difference (purchase.recordRampAdvanceRefusal does):
	//
	//   - CurrentStep == stepNumber reports ErrRampStepCountedBySibling. This
	//     is the issue #1669 scenario: an operator retries two separately-failed
	//     accounts of one ramp step, and the second completion finds the ramp
	//     already sitting on that step because the first advanced it. The step
	//     WAS counted, so this is routine and is not recorded against the
	//     execution.
	//   - CurrentStep > stepNumber reports ErrRampStepAlreadyCounted. The ramp
	//     moved past the step earlier, so this purchase bought commitment the
	//     plan will never count. That is an anomaly and IS recorded.
	//
	// A step counts as complete only when EVERY cloud account the step fanned
	// out to has bought, not when any one of them has (issue #1861). Until
	// then it reports ErrRampStepIncomplete, which is a "not yet" rather than a
	// failure of the purchase that just completed -- the purchase is real and
	// already recorded; only the plan's position is withheld.
	CompletePlanStep(ctx context.Context, planID string, stepNumber int) error
	// GetStuckRampSteps returns, keyed by plan ID, the plan's next ramp step
	// and how many of that step's executions have a terminal, unsuccessful
	// latest attempt with no retry in flight -- a ramp CompletePlanStep will
	// keep refusing to advance until an operator intervenes. Plans with
	// nothing stuck are absent from the map. Feeds the plan-health
	// ramp_blocked factor (issue #1861).
	GetStuckRampSteps(ctx context.Context) (map[string]RampStepBlock, error)
	// LockPurchasePlanTx reads a plan under a row lock held for the rest of tx.
	// It is the per-plan ramp lock CompletePlanStep takes, exposed so a caller
	// that reads a plan's ramp position and then writes against it does so
	// atomically with respect to concurrent completions and creations.
	// Returns (nil, nil) when the plan does not exist.
	LockPurchasePlanTx(ctx context.Context, tx pgx.Tx, planID string) (*PurchasePlan, error)
	// OccupiedRampStepsInRangeTx returns the steps in [from, to] of planID that
	// already have a fan-out unit which bought or is still working, ascending.
	// Callers about to mint executions for a step range use it to refuse a step
	// that is already covered, which would otherwise re-fan-out across accounts
	// that already committed under a fresh idempotency lineage (issue #1861).
	// Requires the caller to hold LockPurchasePlanTx on planID in the same
	// transaction; without it the answer is advisory and two concurrent callers
	// both see the step free.
	OccupiedRampStepsInRangeTx(ctx context.Context, tx pgx.Tx, planID string, from, to int) ([]int, error)
	// UpdatePurchasePlanTx is the tx-accepting variant of UpdatePurchasePlan.
	// Used from createPlannedPurchases' WithTx block so the per-row
	// SavePurchaseExecutionTx writes and the plan's next_execution_date
	// bump commit atomically — a partial failure leaves no orphaned
	// rows and no stale plan pointer.
	UpdatePurchasePlanTx(ctx context.Context, tx pgx.Tx, plan *PurchasePlan) error
	DeletePurchasePlan(ctx context.Context, planID string) error
	ListPurchasePlans(ctx context.Context, filter PurchasePlanFilter) ([]PurchasePlan, error)

	// Purchase executions
	SavePurchaseExecution(ctx context.Context, execution *PurchaseExecution) error
	GetPendingExecutions(ctx context.Context) ([]PurchaseExecution, error)
	// GetExecutionsByStatuses returns executions in any of the given states,
	// newest first, capped at `limit`. Used by the History handler to render
	// pending + failed + expired alongside completed purchases; the scheduler
	// keeps using GetPendingExecutions (which is narrower and doesn't share
	// this method's status filter) to avoid accidental double-processing of
	// failed / expired rows.
	GetExecutionsByStatuses(ctx context.Context, statuses []string, limit int) ([]PurchaseExecution, error)
	// CountExecutionsByPlanAndStatus returns, keyed by plan ID, the exact
	// number of executions in each of the supplied statuses that last
	// changed state (updated_at) at or after `since` -- not those merely
	// scheduled since then, which for a canceled row is a future date. Rows
	// with a NULL plan_id (direct-execute purchases, and executions orphaned
	// by a deleted plan) belong to no plan and are excluded.
	//
	// Deliberately NOT derived from a GetExecutionsByStatuses page: that
	// method is capped by `limit` across ALL plans, so any caller counting
	// per-plan rows out of it silently understates plans whose executions
	// fall outside the newest `limit` rows. The plan health score
	// (internal/api/plan_health.go) needs exact counts -- an understated
	// count renders a confidently-healthy badge for an unhealthy plan --
	// so the aggregation happens in SQL and the result is bounded by
	// plans x statuses rather than by execution volume.
	CountExecutionsByPlanAndStatus(ctx context.Context, statuses []string, since time.Time) (map[string]ExecutionStatusCounts, error)
	// GetPlannedExecutions returns executions in any of the given states
	// ordered by scheduled_date ASC (soonest first), the order the Planned
	// Purchases UI lists rows so the user acts on imminent purchases first.
	// Distinct from GetExecutionsByStatuses (which is DESC for History's
	// "newest first" semantics): when the result set exceeds `limit`, an
	// ORDER-BY-DESC + LIMIT in SQL truncates away the soonest rows, exactly
	// the rows this list must surface. Secondary sort by id ASC stabilizes
	// ordering when multiple rows share a scheduled_date.
	GetPlannedExecutions(ctx context.Context, statuses []string, limit int) ([]PurchaseExecution, error)
	// GetStaleApprovedExecutions returns executions stuck in the "approved"
	// status with updated_at older than olderThan — strands left behind when a
	// synchronous purchase run was interrupted before finalizing (issue #632).
	// The recovery sweep in the purchase manager re-drives these into a
	// terminal "failed" state so they can never sit permanently approved.
	GetStaleApprovedExecutions(ctx context.Context, olderThan time.Duration) ([]PurchaseExecution, error)
	// GetExecutionByID retrieves a purchase execution by execution ID.
	// Returns an error wrapping ErrNotFound when no execution exists;
	// never returns (nil, nil). A nil error guarantees a non-nil execution.
	GetExecutionByID(ctx context.Context, executionID string) (*PurchaseExecution, error)
	GetExecutionByPlanAndDate(ctx context.Context, planID string, scheduledDate time.Time) (*PurchaseExecution, error)
	// GetUserEmailByID resolves the email address of the auth user identified
	// by userID (the `users` table, owned by internal/auth). Read-only helper
	// used by the 4-eyes approval policy (issue #1005) to compare the acting
	// approver's identity against a PurchaseExecution.CreatedByUserID without
	// internal/purchase importing internal/auth (which would create an import
	// cycle: internal/auth already imports internal/config). Returns ("", nil)
	// when no user matches userID — the caller treats an empty result as
	// "identity unresolved" and fails closed, never as "email intentionally
	// blank".
	GetUserEmailByID(ctx context.Context, userID string) (string, error)
	// CountPendingExecutionsForAccount returns the number of purchase_executions
	// in status 'pending' or 'notified' that reference the given cloud account.
	// Used by the deleteAccount handler to preflight DB-level FK violations
	// (migration 000053 tightened the FK to ON DELETE RESTRICT) and emit a
	// 409 with a count so the frontend can offer Cancel-All-Then-Delete UX
	// instead of surfacing a raw constraint error. See issue #606.
	CountPendingExecutionsForAccount(ctx context.Context, accountID string) (int, error)
	// ListPendingExecutionIDsForAccount returns the execution IDs of all
	// pending / notified executions referencing this account, used by the
	// frontend's Cancel-All-Then-Delete flow when the operator opts to
	// cancel everything in one go after a 409. Capped at 1000 rows; if a
	// single account has more pending executions than that, the cleanup
	// is a one-off operator task rather than a button click anyway.
	ListPendingExecutionIDsForAccount(ctx context.Context, accountID string) ([]string, error)
	CleanupOldExecutions(ctx context.Context, retentionDays int) (int64, error)
	// TransitionExecutionStatus atomically transitions an execution status.
	// actor is the UUID of the user performing the transition (nil for system-initiated paths).
	// When non-nil the actor is stamped onto transitioned_by + transitioned_at; when nil,
	// transitioned_by is set to NULL and transitioned_at is still set to NOW() for ordering.
	TransitionExecutionStatus(ctx context.Context, executionID string, fromStatuses []string, toStatus string, actor *string) (*PurchaseExecution, error)
	// SetCancelledBy stamps canceled_by and the legacy cancelled_by column on
	// an execution without overwriting any other columns. Used after
	// TransitionExecutionStatus to fold the actor attribution into the same
	// logical write without the full-row SavePurchaseExecution clobber risk
	// (Finding #5 / PR #889).
	SetCancelledBy(ctx context.Context, executionID string, cancelledBy string) error
	// CancelExecutionAtomic atomically flips status from pending / notified
	// to 'canceled' (canonical US spelling), setting canceled_by. The
	// 'scheduled' status is NOT accepted here; scheduled rows are revoked via
	// CancelScheduledExecutionAtomic (Gmail-style pre-fire delay revoke
	// path, issue #291 wave-2) so the two flows surface distinct CAS race
	// outcomes. Returns (true, "canceled", nil) on success and (false,
	// currentStatus, nil) when zero rows were affected (the execution had
	// already been approved or otherwise transitioned). Must be called
	// inside a WithTx block so the suppression cleanup and the status flip
	// commit atomically.
	CancelExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (canceled bool, currentStatus string, err error)
	// CancelScheduledExecutionAtomic atomically flips status from 'scheduled' to
	// 'canceled' (canonical US spelling), setting canceled_by. Used by the
	// Gmail-style pre-fire delay revoke path (issue #291 wave-2) to cancel a
	// scheduled execution at $0 before the scheduler fires the SDK call. The
	// 'pending'/'notified' set accepted by CancelExecutionAtomic is
	// intentionally not extended here so the two revoke flows surface distinct
	// CAS race outcomes -- a scheduled row that the scheduler has already
	// transitioned to 'approved' / 'running' must surface as a 410 ("window
	// closed") rather than a 409 ("not pending"). Returns (true, "canceled",
	// nil) on success and (false, currentStatus, nil) when zero rows were
	// affected. Must be called inside a WithTx block.
	CancelScheduledExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string, cancelledBy *string) (canceled bool, currentStatus string, err error)
	// ListStuckExecutions returns executions in any of the given statuses
	// whose updated_at is older than the given duration. Used by the
	// reaper sweep (issue #678) to find rows stuck in approved/running
	// after the synchronous executor failed mid-flight without flipping
	// them to a terminal state. Oldest-stuck-first (ORDER BY updated_at
	// ASC), capped at MaxListLimit per sweep.
	ListStuckExecutions(ctx context.Context, statuses []string, olderThan time.Duration) ([]PurchaseExecution, error)

	// GetScheduledExecutionsDue returns purchase_executions with
	// status='scheduled' whose scheduled_execution_at is in the past
	// (scheduled_execution_at <= NOW()). Used by the Gmail-style pre-fire
	// delay scheduler tick (issue #291 wave-2) to find rows ready to fire.
	// Oldest-due-first (ORDER BY scheduled_execution_at ASC), capped at
	// MaxListLimit per sweep.
	GetScheduledExecutionsDue(ctx context.Context) ([]PurchaseExecution, error)

	// Purchase history
	SavePurchaseHistory(ctx context.Context, record *PurchaseHistoryRecord) error
	GetPurchaseHistory(ctx context.Context, accountID string, limit int) ([]PurchaseHistoryRecord, error)
	GetAllPurchaseHistory(ctx context.Context, limit int) ([]PurchaseHistoryRecord, error)
	// GetActivePurchaseHistory returns every purchase_history row whose commitment
	// is still within its term at asOf (term > 0 AND timestamp + term years >= asOf;
	// the expiry boundary is inclusive, matching the API layer's isActiveCommitment),
	// newest-first, optionally scoped to a set of accounts. The account scope uses
	// the same dual-column predicate as GetPurchaseHistoryFiltered: accountIDs
	// match cloud_account_id (cloud_accounts UUIDs) and externalIDsByProvider
	// matches account_id per provider, OR'd together so rows carrying only one
	// identifier are still returned (issues #701/#498/#866). Both empty means all
	// accounts. Unlike GetAllPurchaseHistory it is not row-capped: the analytics
	// collector, dashboard KPIs, and inventory endpoints need the complete active
	// set, and filtering expired commitments in SQL keeps the result bounded by
	// the number of live commitments rather than by all history ever recorded (so
	// it cannot silently truncate older-but-still-active 1y/3y commitments the
	// way a newest-first capped page does, issue #1140).
	GetActivePurchaseHistory(ctx context.Context, asOf time.Time, accountIDs []string, externalIDsByProvider map[string][]string) ([]PurchaseHistoryRecord, error)
	// GetPurchaseHistoryFiltered reads purchase_history rows matching the
	// PurchaseHistoryFilter, newest-first, capped at filter.Limit. Each field is
	// applied independently and only when populated (see PurchaseHistoryFilter).
	// The account predicate matches BOTH identifier columns:
	//   (cloud_account_id = ANY(AccountIDs)
	//      OR (provider = $p AND account_id = ANY(ExternalIDsByProvider[p])) OR ...)
	// because purchase_history rows carry the cloud_accounts UUID FK
	// (cloud_account_id, NULL on direct-execute/ambient/pre-000011 rows) and the
	// cloud-provider external number (account_id, always populated) independently.
	// The top-bar Account chip emits the UUID; matching only one column silently
	// dropped rows that carried only the other (issues #701/#498/#866). The caller
	// resolves AccountIDs to their external account numbers grouped by provider
	// and populates ExternalIDsByProvider so rows that carry only account_id are
	// also matched, while the per-provider grouping keeps a reused external number
	// across providers (aws/123 vs azure/123) from leaking the wrong rows.
	GetPurchaseHistoryFiltered(ctx context.Context, filter PurchaseHistoryFilter) ([]PurchaseHistoryRecord, error)
	// GetPurchaseHistoryByPurchaseID returns the single purchase_history row
	// whose purchase_id matches (AWS ReservedInstancesId / Azure reservation
	// ID). Returns (nil, nil) when no row is found. Used by the revoke
	// endpoint to load the record before calling the provider cancel API
	// (issue #290) and by the marketplace-list handler to validate
	// offering_class and look up the cloud account (issue #292).
	GetPurchaseHistoryByPurchaseID(ctx context.Context, purchaseID string) (*PurchaseHistoryRecord, error)
	// MarkPurchaseRevoked stamps revoked_at, revoked_via, and optionally
	// support_case_id on a purchase_history row identified by purchase_id.
	// calcRefundAmount and calcRefundCurrency capture the Azure CalculateRefund
	// quote for audit (migration 000071, Finding #4); both nil/empty for
	// non-Azure paths or legacy rows written before the migration.
	// Returns a not-found error when no row matches. Idempotent: a second
	// call for the same row is a no-op (revoked_at is not overwritten when
	// it is already non-null). Used by the revoke endpoint (issue #290).
	MarkPurchaseRevoked(ctx context.Context, purchaseID string, revokedAt time.Time, revokedVia string, supportCaseID string, calcRefundAmount *float64, calcRefundCurrency string) error

	// FlipPurchaseRevocationInFlight atomically sets revocation_in_flight=true
	// on a purchase_history row. Called immediately before the Azure Return API
	// call so that the row can be identified by the finalize sweep if the
	// subsequent MarkPurchaseRevoked DB write fails (partial-success reconciliation,
	// issue #290 Finding #6, migration 000072). No-op when the flag is already
	// true (idempotent). Returns a not-found error when no row matches.
	FlipPurchaseRevocationInFlight(ctx context.Context, purchaseID string) error

	// ClearRevocationInFlight resets revocation_in_flight=false on a
	// purchase_history row. Called when the Azure Return call fails with a
	// transient or client error (not "already returned"), so the row is not
	// left in a permanently-sticky in-flight state that would prevent future
	// retries or mislead the finalize_revocations sweep (issue #290, second-wave
	// CR Finding D). No-op when the row is already false. Best-effort: callers
	// should log on error but not surface it to the user.
	ClearRevocationInFlight(ctx context.Context, purchaseID string) error

	// GetPurchaseHistoryInFlight returns all purchase_history rows with
	// revocation_in_flight=true and revoked_at IS NULL.  These are rows where
	// the Azure Return call succeeded but MarkPurchaseRevoked failed; the
	// finalize_revocations scheduled sweep calls this to retry the DB write.
	GetPurchaseHistoryInFlight(ctx context.Context) ([]*PurchaseHistoryRecord, error)

	// UpdatePurchaseHistoryListing stamps the AWS marketplace listing_id and
	// listing_state onto a purchase_history row. Called after
	// CreateReservedInstancesListing succeeds (listing_state="active") and
	// on subsequent poll/cancel transitions (issue #292).
	UpdatePurchaseHistoryListing(ctx context.Context, purchaseID, listingID, listingState string) error

	// StampOfferingClass writes the offering_class value to a purchase_history
	// row identified by purchase_id. Called by the marketplace-list handler
	// when offering_class is absent in the DB (pre-migration 000087 rows and
	// externally-created Standard RIs): after fetching the class from AWS
	// DescribeReservedInstances it is persisted so subsequent requests do not
	// incur an extra AWS API call.
	StampOfferingClass(ctx context.Context, purchaseID, offeringClass string) error

	// ClaimMarketplaceListingSlot atomically reserves the marketplace-listing
	// slot for a purchase_history row so two concurrent marketplace-list
	// requests cannot both proceed to create a duplicate AWS listing (issue
	// #292). It transitions listing_state to ListingStatePending only when the
	// row is not already listed or mid-listing, and reports whether this call
	// won the claim: (true, nil) means the caller reserved the slot and must
	// then persist the real listing on success or release the slot back to its
	// prior state on failure; (false, nil) means another request already holds
	// an active or pending listing (the caller maps this to a 409). Modeled on
	// FlipPurchaseRevocationInFlight.
	ClaimMarketplaceListingSlot(ctx context.Context, purchaseID string) (bool, error)

	// ClaimRIExchangeIdempotencyKey atomically claims key for an RI exchange
	// submit, so a client that retries a timed-out execute request cannot
	// commit the same exchange twice (issue #1642). key is a fingerprint of
	// what the request buys; the caller derives it and claims it immediately
	// before the irreversible provider call.
	//
	// Returns (true, nil) when this call won the claim and may proceed to
	// commit, and (false, nil) when another submit of the same fingerprint
	// claimed it less than window ago (the caller maps this to a 409).
	// A claim older than window is reclaimable, so a genuine intentional
	// repeat of the same exchange is not blocked forever.
	//
	// There is no release: once taken, a claim stands for the rest of the
	// window whatever the provider call does, including when it fails. Past
	// the point of submission the outcome is ambiguous, and holding the claim
	// is the fail-closed choice. The consequence for the caller's 409 is that
	// it cannot promise the earlier submit committed, only that it claimed the
	// window.
	ClaimRIExchangeIdempotencyKey(ctx context.Context, key string, window time.Duration) (bool, error)

	// RI Exchange history
	SaveRIExchangeRecord(ctx context.Context, record *RIExchangeRecord) error
	GetRIExchangeRecord(ctx context.Context, id string) (*RIExchangeRecord, error)
	GetRIExchangeRecordByToken(ctx context.Context, token string) (*RIExchangeRecord, error)
	GetRIExchangeHistory(ctx context.Context, since time.Time, limit int) ([]RIExchangeRecord, error)
	// TransitionRIExchangeStatus atomically transitions an RI exchange record status.
	// actor is the UUID of the user performing the transition (nil for system-initiated paths).
	TransitionRIExchangeStatus(ctx context.Context, id string, fromStatus string, toStatus string, actor *string) (*RIExchangeRecord, error)
	CompleteRIExchange(ctx context.Context, id string, exchangeID string) error
	// CompleteRIExchangeWithPayment marks an RI exchange as completed and
	// updates payment_due to the amount AWS actually accepted. Use this instead
	// of CompleteRIExchange on the manual-approval path so the daily-spend
	// ledger (GetRIExchangeDailySpend) reflects the real accepted amount rather
	// than the stale pre-execution quote (H3 fix).
	CompleteRIExchangeWithPayment(ctx context.Context, id string, exchangeID string, acceptedPaymentDue string) error
	// StampRIExchangeApprovedBy sets the approved_by column on a completed
	// exchange row (issue #300). Called after CompleteRIExchangeWithPayment when
	// the approval came from a session-authed user rather than an email token.
	StampRIExchangeApprovedBy(ctx context.Context, id string, approverEmail string) error
	FailRIExchange(ctx context.Context, id string, errorMsg string) error
	GetRIExchangeDailySpend(ctx context.Context, date time.Time) (string, error)
	CancelAllPendingExchanges(ctx context.Context) (int64, error)
	// CancelPendingExchangesByOrigin cancels only pending records whose origin
	// matches:
	//   - common.ExchangeOriginStandalone: cancels WHERE ladder_run_id IS NULL
	//   - common.ExchangeOriginLadder:     cancels WHERE ladder_run_id IS NOT NULL
	// The origin is validated at the boundary and an unknown value fails loud.
	// This prevents the standalone ri_exchange_reshape task from wiping out
	// ladder-linked pending reshapes and vice versa (gap G10 / issue #1348).
	CancelPendingExchangesByOrigin(ctx context.Context, origin common.ExchangeOrigin) (int64, error)
	GetStaleProcessingExchanges(ctx context.Context, olderThan time.Duration) ([]RIExchangeRecord, error)

	// Cloud accounts
	CreateCloudAccount(ctx context.Context, account *CloudAccount) error
	GetCloudAccount(ctx context.Context, id string) (*CloudAccount, error)
	// GetCloudAccountByExternalID looks up a cloud account by its
	// (provider, external_id) pair. Used by the scheduler's ambient
	// collector path to tag rec rows with the registered host-account's
	// UUID when the Lambda's STS identity matches a registered (but
	// possibly disabled) account — so the approve-modal Account column
	// shows the account name instead of `(ambient)`. Returns
	// (nil, nil) when no row matches (the caller treats this as the
	// genuine orphan case). The underlying
	// `UNIQUE(provider, external_id)` constraint on cloud_accounts
	// guarantees the lookup hits an index.
	GetCloudAccountByExternalID(ctx context.Context, provider, externalID string) (*CloudAccount, error)
	UpdateCloudAccount(ctx context.Context, account *CloudAccount) error
	DeleteCloudAccount(ctx context.Context, id string) error
	ListCloudAccounts(ctx context.Context, filter CloudAccountFilter) ([]CloudAccount, error)

	// Account credentials (encrypted blobs; never returned via API)
	SaveAccountCredential(ctx context.Context, accountID, credentialType, encryptedBlob string) error
	GetAccountCredential(ctx context.Context, accountID, credentialType string) (string, error)
	DeleteAccountCredentials(ctx context.Context, accountID string) error
	HasAccountCredentials(ctx context.Context, accountID string) (bool, error)

	// Account service overrides
	GetAccountServiceOverride(ctx context.Context, accountID, provider, service string) (*AccountServiceOverride, error)
	SaveAccountServiceOverride(ctx context.Context, override *AccountServiceOverride) error
	DeleteAccountServiceOverride(ctx context.Context, accountID, provider, service string) error
	ListAccountServiceOverrides(ctx context.Context, accountID string) ([]AccountServiceOverride, error)

	// Plan ↔ account association
	SetPlanAccounts(ctx context.Context, planID string, accountIDs []string) error
	GetPlanAccounts(ctx context.Context, planID string) ([]CloudAccount, error)

	// Recommendations cache (ADR: store recommendations in Postgres so the
	// dashboard serves provider-switch clicks from SQL instead of live cloud
	// API calls). ReplaceRecommendations is the "force full resync" path;
	// UpsertRecommendations is the steady-state write path and takes a list
	// of (provider, account) pairs that successfully collected this cycle.
	// Stale-row eviction is scoped to that union so a partially-failed
	// provider preserves the failed accounts' previous-cycle rows. See
	// SuccessfulCollect for the per-row semantics.
	ReplaceRecommendations(ctx context.Context, collectedAt time.Time, recs []RecommendationRecord) error
	UpsertRecommendations(ctx context.Context, collectedAt time.Time, recs []RecommendationRecord, successfulCollects []SuccessfulCollect) error
	ListStoredRecommendations(ctx context.Context, filter RecommendationFilter) ([]RecommendationRecord, error)
	GetRecommendationsFreshness(ctx context.Context) (*RecommendationsFreshness, error)
	SetRecommendationsCollectionError(ctx context.Context, errMsg string) error
	// MarkCollectionStarted atomically sets last_collection_started_at = now
	// only when no in-flight collection is running (last_collection_started_at IS NULL
	// OR older than 5 minutes), and stamps a freshly generated owner token
	// alongside it. Returns the token and true when this caller won the race
	// and should proceed with the async invoke, passing the token through so
	// only this caller can later clear the marker; ("", false, nil) when
	// another collection is already in flight and the caller should return
	// 409.
	MarkCollectionStarted(ctx context.Context) (token string, ok bool, err error)
	// ClearCollectionStarted clears last_collection_started_at, but only if
	// last_collection_owner_id still matches token: a compare-and-clear
	// guard (issue #261) so a caller that never won MarkCollectionStarted
	// (cron, cold-start) cannot wipe another caller's in-flight marker. A
	// mismatched token is a documented silent no-op (the marker belongs to
	// someone else); an empty token is a boundary error, since only a
	// caller that actually owns a marker should ever call Clear.
	ClearCollectionStarted(ctx context.Context, token string) error

	// RI utilization cache. Postgres-backed TTL cache for Cost Explorer
	// GetReservationUtilization; shared across Lambda containers so
	// dashboard loads don't each fan out to a paid CE API call. A
	// per-process in-memory cache effectively never hits because each
	// cold container starts empty. GetRIUtilizationCache returns nil
	// when the (region, lookback_days) key is absent — callers treat
	// that as a miss and re-fetch.
	GetRIUtilizationCache(ctx context.Context, region string, lookbackDays int) (*RIUtilizationCacheEntry, error)
	UpsertRIUtilizationCache(ctx context.Context, region string, lookbackDays int, payload []byte, fetchedAt time.Time) error

	// Account registrations (self-service enrollment via federation IaC)
	CreateAccountRegistration(ctx context.Context, reg *AccountRegistration) error
	GetAccountRegistration(ctx context.Context, id string) (*AccountRegistration, error)
	GetAccountRegistrationByToken(ctx context.Context, token string) (*AccountRegistration, error)
	ListAccountRegistrations(ctx context.Context, filter AccountRegistrationFilter) ([]AccountRegistration, error)
	UpdateAccountRegistration(ctx context.Context, reg *AccountRegistration) error
	// TransitionRegistrationStatus atomically updates a registration's workflow fields.
	// actor is the UUID of the reviewer (nil for system-initiated transitions).
	TransitionRegistrationStatus(ctx context.Context, reg *AccountRegistration, fromStatus string, actor *string) error
	DeleteAccountRegistration(ctx context.Context, id string) error

	// Purchase suppressions. Written inside a WithTx block during bulk
	// purchase submit so the execution insert + the suppression rows
	// commit atomically. Deleted on cancel/expire of the execution,
	// also inside a WithTx block paired with the status update.
	//
	// The plain variants (no Tx) open their own single-call transaction
	// — useful for tests and one-off admin operations. The Tx variants
	// reuse a caller-provided transaction so multi-write operations
	// can roll back atomically.
	CreateSuppression(ctx context.Context, sup *PurchaseSuppression) error
	CreateSuppressionTx(ctx context.Context, tx pgx.Tx, sup *PurchaseSuppression) error
	DeleteSuppressionsByExecution(ctx context.Context, executionID string) error
	DeleteSuppressionsByExecutionTx(ctx context.Context, tx pgx.Tx, executionID string) error
	ListActiveSuppressions(ctx context.Context) ([]PurchaseSuppression, error)

	// SavePurchaseExecutionTx is the tx-accepting variant of
	// SavePurchaseExecution. Used from executePurchase's WithTx block
	// so the execution insert + suppression writes commit atomically.
	SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, execution *PurchaseExecution) error

	// GetPendingExecutionsTx is the tx-accepting variant of
	// GetPendingExecutions. Used inside the executePurchase WithTx block
	// so the duplicate-detection read and the new-execution insert are
	// atomic under the same transaction, eliminating the TOCTOU race (#643).
	GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ([]PurchaseExecution, error)

	// WithTx opens a pgx transaction, runs fn, and commits on success or
	// rolls back on error. fn can call any *Tx method on the store to
	// participate in the transaction. Nested transactions are not
	// supported — fn must not call WithTx recursively.
	WithTx(ctx context.Context, fn func(tx pgx.Tx) error) error

	// Ladder configuration (per-account, per-provider).
	// GetLadderConfigs returns all rows, newest first.
	// GetLadderConfig returns the single row for (cloudAccountID, provider),
	// or (nil, nil) when no row exists.
	// UpsertLadderConfig inserts or updates via the UNIQUE(cloud_account_id, provider)
	// constraint and returns the persisted row with all DB-stamped fields populated.
	GetLadderConfigs(ctx context.Context) ([]LadderConfigDB, error)
	GetLadderConfig(ctx context.Context, cloudAccountID, provider string) (*LadderConfigDB, error)
	UpsertLadderConfig(ctx context.Context, cfg *LadderConfigDB) (*LadderConfigDB, error)

	// Ladder run/tranche persistence (migration 000080/000081, PR-2).
	//
	// SaveLadderRun inserts a new ladder_runs row, returning the persisted row
	// with all DB-stamped fields (id, created_at, updated_at) populated.
	// If run.ID is empty, a new UUID is generated before the insert.
	//
	// GetLadderRun returns the row for the given id, or (nil, nil) when no
	// row exists (mirrors GetLadderConfig semantics).
	//
	// SaveLadderTranches inserts a batch of ladder_tranches rows inside a
	// single transaction. Each tranche must carry a non-empty ID; duplicate
	// IDs within the batch are rejected at the DB UNIQUE constraint level.
	//
	// LatestLadderRunStartedAt returns the maximum started_at for the given
	// config_id, or nil when no run has been recorded yet. Powers the per-cadence
	// self-gate in the scheduler (Q6).
	//
	// TransitionLadderRunStatus atomically updates the status of a ladder_runs
	// row from one of the fromStatuses to toStatus, returning the updated row.
	// Returns (nil, nil) when zero rows are affected (CAS race lost or wrong
	// current status), so callers can distinguish a race from a hard error.
	// Statuses are typed ladder.RunStatus so callers cannot pass an arbitrary
	// string that would never match a stored status.
	// SaveLadderRunWithTranches inserts the run row and its tranches in ONE
	// transaction: a tranche-insert failure rolls back the run row too, so a
	// status=planned run never persists without its tranches (which would let
	// the cadence gate suppress the retry for a full window).
	//
	// L5 append-only: every ladder run persists its new scheduled tranches via
	// this method and leaves any existing scheduled tranches untouched. In-flight
	// netting (GetInFlightLadderCommitUSDHr) already subtracts existing
	// scheduled tranches from the gap, so each run appends exactly the delta
	// needed to reach target-E. Appending (never superseding) keeps prior
	// tranches' original fire dates, converges to target on drift-up (run N
	// tops up the gap the netting leaves), and cannot oscillate.
	//
	// GetInFlightLadderCommitUSDHr returns the total hourly USD commitment in
	// flight for the given config: the sum of amount_usd_hr for tranches with
	// status = 'scheduled' ONLY. Fired/completed tranches are executed
	// purchases already reflected in the engine's ExistingUSDPerHour (the
	// provider adapters fold payment-pending and active commitments into E),
	// so summing them here too would double-count and under-purchase. Returns
	// a non-nil pointer (zero when no scheduled tranches exist) so callers can
	// pass it directly to AllocationInput.InFlightUSDPerHour. Never returns
	// nil without an error.
	SaveLadderRun(ctx context.Context, run *LadderRunDB) (*LadderRunDB, error)
	SaveLadderRunWithTranches(ctx context.Context, run *LadderRunDB, tranches []LadderTrancheDB) (*LadderRunDB, error)
	GetInFlightLadderCommitUSDHr(ctx context.Context, configID string) (*float64, error)
	GetLadderRun(ctx context.Context, id string) (*LadderRunDB, error)
	SaveLadderTranches(ctx context.Context, tranches []LadderTrancheDB) error
	LatestLadderRunStartedAt(ctx context.Context, configID string) (*time.Time, error)
	TransitionLadderRunStatus(ctx context.Context, id string, fromStatuses []ladder.RunStatus, toStatus ladder.RunStatus) (*LadderRunDB, error)

	// Notification mutes (issue #297 / migration 000091).
	// UpsertNotificationMute inserts or updates a mute row for (email, scope).
	// Idempotent for row existence: calling it again for an already-muted
	// address refreshes muted_at and replaces unmute_token if the token changes.
	UpsertNotificationMute(ctx context.Context, recipientEmail, scope, unmuteToken string) error
	// IsNotificationMuted returns true when (email, scope) has a row in
	// muted_recipients. The email comparison is case-insensitive.
	IsNotificationMuted(ctx context.Context, recipientEmail, scope string) (bool, error)
}

StoreInterface defines the methods required for configuration storage.

type SuccessfulCollect

type SuccessfulCollect struct {
	Provider       string
	CloudAccountID *string
}

SuccessfulCollect identifies a (provider, account) pair whose collection completed in the current cycle. UpsertRecommendations scopes the stale-row eviction DELETE to the union of these pairs so a partially- failed provider preserves the failed accounts' previous-cycle rows (the dashboard for those accounts isn't blanked out by transient cloud-API failures).

CloudAccountID is nil for the AWS ambient-credentials path (no registered account); the eviction collapses nil to the zero UUID via the same generated-column rule that applies to inserts, so ambient rows are evicted independently of any registered-account rows under the same provider.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL