config

package
v0.0.0-...-8caed39 Latest Latest
Warning

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

Go to latest
Published: Oct 18, 2026 License: OSL-3.0 Imports: 27 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 (
	MinLadderHourlyCommit      = 0.000001
	MaxLadderHourlyCommitLimit = 1e12
)

MinLadderHourlyCommit and MaxLadderHourlyCommitLimit bound ladder_configs.max_hourly_commit_per_run. Below one micro-dollar the NUMERIC(20,6) column rounds to 0; the upper bound keeps the micro-dollar value inside int64 and far above any real hourly commitment.

View Source
const (
	FanOutShapeSingle = "single"
	FanOutShapeMulti  = "multi"
)

Fan-out shapes of a purchase execution lineage (issue #97). The shape decides which key the per-rec provider idempotency tokens derive from: the lineage key itself on the single-account path, "<lineage key>:<account id>" per account on the fan-out path.

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 (
	PartialReasonPermissionDenied    = "permission_denied"
	PartialReasonThrottled           = "throttled"
	PartialReasonTimeout             = "timeout"
	PartialReasonCanceled            = "canceled"
	PartialReasonNotFound            = "not_found"
	PartialReasonProviderUnavailable = "provider_unavailable"
	PartialReasonOther               = "other"
)

Reason categories for PartialProviderSweep.Reasons. The set is closed so the API never echoes provider error text.

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 (
	MinStepCommitment      = 0.01
	MaxStepCommitmentLimit = 1e15
)

MinStepCommitment and MaxStepCommitmentLimit bound max_step_commitment to what the NUMERIC(18,2) column stores: below one cent it rounds to 0 and violates CHECK > 0 (a 500), and 1e308 overflows it. NaN fails both comparisons.

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 LadderChainMaxDepth = 20

LadderChainMaxDepth caps how many executions the reconciler follows along a retry chain (head = the execution the tranche was claimed with). A longer chain is returned truncated, which the reconciler treats as unresolved.

View Source
const LadderFireGrace = 24 * time.Hour

LadderFireGrace is how late a scheduled ladder tranche may still be fired. It is the single definition behind both sides of the boundary: in-flight netting counts a scheduled tranche while it is still fireable, and the overdue sweep cancels it once it is not. Keeping both on ladderGraceCutoff guarantees every scheduled row is counted or canceled, never neither (neither would let the next run re-plan an amount the executor can still buy, and both would be bought) and never both.

View Source
const LadderTimelinePageSize = 100
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 PreApprovalSkipRampTargetMet = "ramp-target-met"

PreApprovalSkipRampTargetMet marks the completed no-op row a ramp step gets when its cumulative target is already held (platform#631). The advance gate (requireRampStepBought) is unchanged and still counts only completed or partially_completed rows as bought, so a failed zero-buy fan-out can never advance the ramp; only CompleteTargetMetStepTx writes this row, in the claim transaction, and the marker tells it apart from a purchase.

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.rotateApprovalToken) 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.

View Source
const StatusRevocationRequested = "revocation_requested"

StatusRevocationRequested records that an operator asked for a completed purchase to be revoked. The cloud-side revocation happens outside this system (an AWS support case), and no code moves a row out of this status.

Variables

View Source
var (
	ErrLadderAmendNotFound = errors.New("ladder tranche not found")
	ErrLadderAmendConflict = errors.New("ladder tranche changed or is no longer editable")
	ErrLadderAmendInvalid  = errors.New("ladder amendment exceeds the original run total, the current per-run cap or the config commitment ceiling")
)
View Source
var (
	ErrLadderResolveNotFound = errors.New("ladder tranche not found")
	ErrLadderResolveInvalid  = errors.New("invalid ladder resolve request")
	ErrLadderResolveConflict = errors.New("ladder tranche cannot be resolved")
)

Outcomes of an operator resolve that the store distinguishes. The handler maps them to 404, 400 and 409.

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 ErrApprovalTokenChanged = errors.New("approval token no longer matches the execution")

ErrApprovalTokenChanged is returned (wrapped) by TransitionExecutionStatusTokenPinned when the execution is still in an allowed status but its stored approval token no longer matches the one the caller supplied, or has expired: the token was rotated (re-notification) or lapsed after the caller validated it and before the transition (issue #590).

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 ErrAzurePricingNotReady = errors.New("azure pricing migration not ready")
View Source
var ErrDuplicatePendingRegistration = errors.New("a pending registration already exists for this account")

ErrDuplicatePendingRegistration is returned by CreateAccountRegistration when a pending registration already exists for the same provider+external_id.

View Source
var ErrEditorReconciliationRequired = errors.New("plan is managed by the schedule editor")

ErrEditorReconciliationRequired means the plan is editor-managed and a legacy writer tried to change what only the schedule editor may. Permanent for the request: callers map it to 409; a background caller would skip, not retry.

View Source
var ErrEmptyPurchaseID = errors.New("purchase history record has no purchase id")

ErrEmptyPurchaseID is returned by SavePurchaseHistory for a record with no purchase_id. History is saved once per (provider, account_id, purchase_id), so an empty ID would be silently dropped after the first one (#774).

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 ErrExecutionRetryLinked = errors.New("execution already has a retry successor")

ErrExecutionRetryLinked is returned by RecoverReapedExecutionTx when the reaped row already has a retry successor linked. Overwriting it could mean two live purchases for one logical step, so the caller must fail loud and flag a possible double buy instead of recovering.

View Source
var ErrLadderLockTimeout = errors.New("ladder config lock timed out")

ErrLadderLockTimeout means the per-config ladder lock was not acquired within ladderLockTimeout (SQLSTATE 55P03). The operation did nothing; retry later.

View Source
var ErrLadderRunCancelActorRequired = errors.New("canceling a ladder run requires the actor's email")

ErrLadderRunCancelActorRequired is returned by CancelLadderRun when no actor is supplied, so a ladder run can never be canceled with a NULL canceled-by audit column.

View Source
var ErrLadderRunCancelViaTransition = errors.New("ladder run cancel must use CancelLadderRun so the actor is recorded")

ErrLadderRunCancelViaTransition is returned by TransitionLadderRunStatus for a transition to RunStatusCancelled: that path cannot stamp the canceled-by audit column, so cancels must go through CancelLadderRun (issue #542).

View Source
var ErrLadderTimelineRate = errors.New("ladder timeline row has an unreadable amount basis or commitment rate")

ErrLadderTimelineRate marks a stored amount basis or rate that cannot be converted; callers must not fall back to the native amount.

View Source
var ErrLadderTrancheNotFired = errors.New("ladder tranche is not fired")

ErrLadderTrancheNotFired means the tranche row is gone or no longer fired when the reconciler locked it: another writer won, nothing to do.

View Source
var ErrManagedPlanLiveExecutions = errors.New("managed plan still has active or unsettled purchases")

ErrManagedPlanLiveExecutions means deleting an editor-managed plan would detach an execution that may still hold or buy capacity.

View Source
var ErrMultiStepSavingsPlans = errors.New("multi-step ramps do not support Savings Plans: use a single-step (immediate) ramp or remove the Savings Plans service")

ErrMultiStepSavingsPlans marks an armed multi-step plan with an enabled Savings Plans service. Step 1 suppresses the whole SP recommendation for 7 days, so later steps find nothing and the ramp strands (platform#631, Q7). Read-only query for already-armed hits: runbooks/armed-multi-step-savings-plans-plans.md.

View Source
var ErrNoVetoWindow = errors.New("plan has no veto window")

ErrNoVetoWindow marks a plan whose notification window is empty: with NotificationDaysBefore < 1 the email goes out when the purchase is already due, so the human veto the notified status stands for has no time to happen.

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

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

View Source
var ErrPurchasePlanConflict = errors.New("purchase plan changed")

ErrPurchasePlanConflict means a plan changed after the caller read it.

View Source
var ErrRIExchangeDailyCapExceeded = errors.New("RI exchange daily cap exceeded")

ErrRIExchangeDailyCapExceeded marks a reservation refused because the initial quote does not fit the remaining daily or per-exchange headroom.

View Source
var ErrRampLedgerUntrusted = errors.New("this ramp's purchase history from before the retention ledger cannot be verified, so it cannot size further steps")
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 ErrReferenceTokenCollision = errors.New("registration reference token collision")

ErrReferenceTokenCollision is returned by CreateAccountRegistration when the generated reference_token is already taken. The caller should regenerate the token and retry; it says nothing about whether the account is registered.

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.

TerminalExecutionStatuses are the execution statuses that no executor, scheduler or reaper will act on again. DeleteCloudAccount detaches rows in these statuses from the account being deleted (cloud_account_id = NULL, the same treatment purchase_history gets from its ON DELETE SET NULL FK) so the history survives the delete, and CountLiveExecutionsForAccount counts every other row as one that still needs the account (issue #265).

It is an ALLOWLIST on purpose: a status added to the schema later counts as live, and so blocks the delete, until someone classifies it here. Blocking a delete is recoverable; detaching an execution that will still run is not, because a row with no account falls back to plan fan-out or ambient credentials.

A failed row is listed even though it can be retried. A retry of a row detached here still carries the deleted account on its recommendations, so the executor refuses it before submission ("account not found") instead of buying elsewhere.

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 ApprovalTokenMatches

func ApprovalTokenMatches(storedHash, rawSupplied string) bool

ApprovalTokenMatches reports whether rawSupplied is the token that hashes to storedHash, using a constant-time comparison of the two fixed-length hex digests (so neither the digest length nor a byte-by-byte early exit leaks timing information). Returns false whenever either side is empty -- an empty stored hash (no token set) must never be treated as satisfied by an empty supplied token.

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 HashApprovalToken

func HashApprovalToken(rawToken string) string

HashApprovalToken returns the SHA-256 hex digest of a raw approval / revocation / rejection token, in the exact format persisted to the approval_token_hash column (purchase_executions, ri_exchange_history). Mirrors internal/auth's unexported hashSessionToken so every long-lived secret token in this codebase is hashed the same way before it touches storage (issue #103: the approval_token columns these replace held the raw, directly usable secret).

Empty input returns "" so "no token" continues to mean an empty/NULL column value rather than the hash of an empty string -- callers must not treat HashApprovalToken("") as a valid stored value.

func LadderCapMicros

func LadderCapMicros(v float64) (int64, error)

LadderCapMicros returns the per-run hourly cap in exact micro-dollars. Caps with more than 6 decimals are rejected instead of rounded, so the stored and the compared value are identical (same approach as StepCapCents).

func LadderInFlightOnDemand

func LadderInFlightOnDemand(rows []LadderInFlightRow) (float64, error)

LadderInFlightOnDemand is the rows' on-demand-equivalent total, summed exactly and rounded up to the next float only when the float is below it.

func LadderTrancheSettled

func LadderTrancheSettled(status ladder.TrancheStatus) bool

LadderTrancheSettled reports whether a tranche status can no longer lead to a purchase through its existing execution: anything but scheduled or fired. Unknown statuses count as settled, so Retry fails closed.

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 RecIdentityKey

func RecIdentityKey(rec *RecommendationRecord) (string, error)

RecIdentityKey is the tuple a price is a function of: the same eleven components (the ninth is the zonal EC2 availability zone, "" for every other rec, issue #792; the tenth and eleventh are the EC2 platform and tenancy read from Details, "" when absent, issue #806) behind RecommendationRecord.ID, with a nil and an empty CloudAccountID collapsed together (as purchaseConstraintSets and the store's account_key both do). Provider and payment are lowercased because validatePurchaseRecommendation canonicalises the request side.

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.

A nil CloudAccountID uses an empty account key and resolves only global configuration. Triples with neither global configuration nor an account override are omitted from the map.

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 StepCapCents

func StepCapCents(v float64) (int64, error)

StepCapCents returns the cap in whole cents, exactly. The column is NUMERIC(18,2) and Postgres rounds the decimal text pgx sends (the shortest representation of the float), so a cap with more than two decimals would be stored as a different value than the one compared (1.005 stores as 1.01). Such caps are rejected instead of rounded, which makes the stored and the compared value identical. The decimal text is parsed, not multiplied, because v*100 loses exactness near the 1e15 limit.

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 ComputeIdentity

type ComputeIdentity struct {
	Platform string
	Tenancy  string
}

ComputeIdentity is the EC2 platform and tenancy of a recommendation, the two Details fields that distinguish otherwise identical recommendations (issue #806). Values are VERBATIM: AWS platform is the raw Cost Explorer string ("Linux/UNIX"), so no trimming or case folding.

func (ComputeIdentity) IsZero

func (c ComputeIdentity) IsZero() bool

IsZero reports whether neither field is known (non-compute recs, legacy rows without Details).

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"`

	// AutoPurchaseEnabled is the global kill switch for AutoPurchase plans
	// (migration 000112; the scheduling.auto_purchase default). Default false:
	// no AutoPurchase plan notifies or executes until an operator opts in.
	// Manual purchases (Opportunities, token approval) are unaffected.
	AutoPurchaseEnabled bool `json:"auto_purchase_enabled" db:"auto_purchase_enabled"`

	// AutoPurchaseChanged{By,At,Via} stamp the last change of the switch
	// (migration 000115): actor, time, credential kind. NULL = never changed.
	// json:"-" so PUT /api/config cannot forge them by unmarshalling onto this
	// struct; the whole-config upsert round-trips them (read, modify, write).
	AutoPurchaseChangedBy  *string    `json:"-" db:"auto_purchase_changed_by"`
	AutoPurchaseChangedAt  *time.Time `json:"-" db:"auto_purchase_changed_at"`
	AutoPurchaseChangedVia *string    `json:"-" db:"auto_purchase_changed_via"`

	// LadderExecutionChanged{By,At,Via} stamp the last change of
	// LadderExecutionEnabled (migration 000116); same json:"-" reasoning.
	LadderExecutionChangedBy  *string    `json:"-" db:"ladder_execution_changed_by"`
	LadderExecutionChangedAt  *time.Time `json:"-" db:"ladder_execution_changed_at"`
	LadderExecutionChangedVia *string    `json:"-" db:"ladder_execution_changed_via"`

	// 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 LadderAmendment

type LadderAmendment struct {
	ExpectedRevision *int64    `json:"expected_revision"`
	ScheduledDate    time.Time `json:"scheduled_date"`
	AmountUSDHr      string    `json:"amount_usd_hr"`
}

func (LadderAmendment) Validate

func (a LadderAmendment) Validate() error

type LadderAmendmentResult

type LadderAmendmentResult struct {
	ID            string    `json:"id"`
	Revision      int64     `json:"revision"`
	ScheduledDate time.Time `json:"scheduled_date"`
	AmountUSDHr   string    `json:"amount_usd_hr"`
	RunTotalUSDHr string    `json:"run_total_usd_hr"`
}

type LadderAmendmentStore

type LadderAmendmentStore interface {
	LadderTrancheScope(context.Context, string) (string, string, error)
	AmendLadderTranche(context.Context, string, string, string, string, LadderAmendment) (*LadderAmendmentResult, error)
}

type LadderAmountBasis

type LadderAmountBasis string
const (
	LadderAmountOnDemand   LadderAmountBasis = "on_demand_equivalent"
	LadderAmountCommitment LadderAmountBasis = "commitment"
)

type LadderChainNode

type LadderChainNode struct {
	ExecutionID      string
	Status           string
	NoSubmission     bool
	RetryExecutionID *string
	CreatedAt        time.Time
	UpdatedAt        time.Time
}

LadderChainNode is the part of one purchase_executions row the reconciler reads. NoSubmission is only meaningful while Status is failed.

type LadderCloudCheck

type LadderCloudCheck struct {
	AccountID string
	Region    string
	Service   string
	CheckedAt time.Time
}

LadderCloudCheck is the operator's statement of where they looked for the purchase before declaring an unproven tranche failed.

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 LadderExecutionGate

type LadderExecutionGate struct {
	ConfigEnabled *bool
	ConfigMode    *string
	RunStatus     *string
	TrancheStatus *string
}

LadderExecutionGate is what the executor re-reads, right before a provider call, about the ladder tranche an execution belongs to. Each field is a pointer because the foreign keys are ON DELETE SET NULL: a nil field means the row is gone, which the caller must treat as "refuse", not as a default. TrancheStatus is the tranche's own status: an operator may have settled the tranche while an execution carrying its key is still waiting to run.

type LadderInFlightRow

type LadderInFlightRow struct {
	Layer                ladder.LayerType
	CommitmentUSDPerHour float64
	Rate                 float64
	// contains filtered or unexported fields
}

LadderInFlightRow is one netted tranche. Rate is rounded down and the commitment up, so CommitmentUSDPerHour/Rate never understates the exact on-demand-equivalent amount. Legacy on-demand rows carry Rate 1.

type LadderPlanBuilder

type LadderPlanBuilder func(inFlight []LadderInFlightRow) (*LadderRunDB, []LadderTrancheDB, error)

LadderPlanBuilder computes a run and its new tranches from the config's netted tranches. It runs while the per-config lock is held inside the persist transaction: it must be pure compute (no provider or network I/O).

type LadderResolveFailed

type LadderResolveFailed struct {
	TrancheID    string
	AccountID    string
	StatusReason string
	Acknowledge  bool
	Check        *LadderCloudCheck
	Now          time.Time
	// StaleAfter is how long the chain must have been unchanged before an
	// unknown outcome may be declared failed (purchase.LadderFiredStaleAfter).
	StaleAfter time.Duration
}

LadderResolveFailed asks to move a fired tranche to failed. StatusReason is the complete string to store, built by the caller so that a repeat of the same request compares equal as a whole. Check is set when the caller acknowledges an unknown outcome.

type LadderResolveResult

type LadderResolveResult struct {
	ID           string
	Status       string
	StatusReason string
	Replayed     bool
}

LadderResolveResult is the tranche after the call. Replayed is true when an identical earlier request had already failed it and nothing was written.

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 LadderTimelineAction

type LadderTimelineAction struct {
	DataSources []string `json:"data_sources,omitempty"`
	Action      string   `json:"action"`
	Layer       string   `json:"layer"`
	Rationale   string   `json:"rationale"`
}

type LadderTimelineCursor

type LadderTimelineCursor struct {
	CreatedAt time.Time `json:"created_at"`
	ID        string    `json:"id"`
}

type LadderTimelineEvent

type LadderTimelineEvent struct {
	CreatedAt      time.Time            `json:"created_at"`
	ScheduledDate  time.Time            `json:"scheduled_date"`
	ExecutionID    *string              `json:"execution_id,omitempty"`
	ID             string               `json:"id"`
	RunID          string               `json:"run_id"`
	ConfigID       string               `json:"config_id"`
	AmountUSDHr    string               `json:"amount_usd_hr"`
	AmountBasis    *LadderAmountBasis   `json:"amount_basis,omitempty"`
	CommitmentRate *string              `json:"commitment_rate,omitempty"`
	ODUSDHr        string               `json:"od_usd_hr"`
	Revision       int64                `json:"revision"`
	Layer          ladder.LayerType     `json:"layer_type"`
	Term           ladder.Term          `json:"term"`
	Payment        ladder.PaymentOption `json:"payment_option"`
	Status         ladder.TrancheStatus `json:"status"`
	RunStatus      ladder.RunStatus     `json:"run_status"`
}

type LadderTimelinePage

type LadderTimelinePage struct {
	Events       []LadderTimelineEvent `json:"events"`
	NextCursor   *LadderTimelineCursor `json:"next_cursor,omitempty"`
	TotalUSDHr   string                `json:"total_usd_hr"`
	TotalODUSDHr string                `json:"total_od_usd_hr"`
	TotalCount   int64                 `json:"total_count"`
}

type LadderTimelineRun

type LadderTimelineRun struct {
	Actions           []LadderTimelineAction `json:"actions"`
	StartedAt         time.Time              `json:"started_at"`
	CreatedAt         time.Time              `json:"created_at"`
	BaselineUSDHr     *string                `json:"baseline_usd_hr"`
	TargetUSDHr       *string                `json:"target_usd_hr"`
	ExistingUSDHr     *string                `json:"existing_usd_hr"`
	GapUSDHr          *string                `json:"gap_usd_hr"`
	ID                string                 `json:"id"`
	ConfigID          string                 `json:"config_id"`
	TotalHourlyCommit string                 `json:"total_hourly_commit"`
	Status            ladder.RunStatus       `json:"status"`
}

type LadderTimelineRunPage

type LadderTimelineRunPage struct {
	Runs       []LadderTimelineRun   `json:"runs"`
	NextCursor *LadderTimelineCursor `json:"next_cursor,omitempty"`
	TotalCount int64                 `json:"total_count"`
}

type LadderTimelineStore

type LadderTimelineStore interface {
	ListLadderTimeline(context.Context, string, string, *LadderTimelineCursor) (*LadderTimelinePage, error)
	ListLadderTimelineRuns(context.Context, string, string, *LadderTimelineCursor) (*LadderTimelineRunPage, error)
}

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
	// SettledAt is stamped by TransitionLadderTrancheTx when the tranche
	// becomes completed; a CHECK requires it on every completed row.
	SettledAt *time.Time `json:"settled_at,omitempty"`
	// Nil basis preserves unproven history; native rates are lossless rational text.
	AmountBasis    *LadderAmountBasis `json:"amount_basis,omitempty"`
	CommitmentRate *string            `json:"commitment_rate,omitempty"`

	// 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 MarketplaceListingClaim

type MarketplaceListingClaim struct {
	// ListingID is the listing recorded on the row when it was claimed.
	ListingID string
	// PriorState is the listing_state before the claim; empty when none.
	PriorState string
	// ClientToken is the token of this attempt: the one passed to the claim, or
	// the persisted one when an unresolved attempt already held a token.
	ClientToken string //nolint:gosec // G117: an idempotency token for AWS, not a credential
	// PriceSchedule is the JSON price schedule stored with ClientToken.
	PriceSchedule []byte
	// Resumed reports that the token and schedule were already persisted by an
	// earlier attempt that never recorded a listing. It is derived from the
	// returned row (its token differs from the one this claim tried to write),
	// so it stays correct when two claims race. PriorState is then "pending",
	// the state the earlier attempt left; releasing restores an empty state
	// and the row keeps its recorded listing id, which every list, cancel and
	// claim check treats the same as the original dead state.
	Resumed bool
}

MarketplaceListingClaim is what ClaimMarketplaceListingSlot reads back from the row it just claimed, so the handler works from the row as it is at claim time instead of a copy read before the claim (issue #525).

type PartialProviderSweep

type PartialProviderSweep struct {
	Provider string `json:"provider"`
	// IncompleteAccounts is how many accounts (registered, or the single
	// ambient host identity) returned rows from an incomplete sweep.
	IncompleteAccounts int `json:"incomplete_accounts"`
	// AttemptedScopes is how many scopes the incomplete sweeps were asked to
	// cover. Zero when the provider does not report it (AWS).
	AttemptedScopes int `json:"attempted_scopes"`
	// FailedScopes is how many of those scopes could not be queried.
	FailedScopes int `json:"failed_scopes"`
	// FailedDetails is how many individual recommendation details were
	// rejected inside scopes that otherwise answered (AWS only).
	FailedDetails int `json:"failed_details"`
	// Reasons counts the failure causes by PartialReason* category.
	Reasons map[string]int `json:"reasons"`
}

PartialProviderSweep summarizes one provider's incomplete sweeps in a collection. A "scope" is the unit a sweep fails in: a subscription for Azure, a project for GCP, and a service/region collection scope for AWS.

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 PlanRampBaseline

type PlanRampBaseline map[string]PlanRampQuantity

PlanRampBaseline maps RecIdentityKey to the base a ramp plan sizes against.

type PlanRampQuantity

type PlanRampQuantity struct {
	Count  int     `json:"count"`
	Hourly float64 `json:"hourly,omitempty"`
}

PlanRampQuantity is a quantity of one recommendation identity: whole units for count-based recs, the summed hourly_commitment for Savings Plans.

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) AmendLadderTranche

func (s *PostgresStore) AmendLadderTranche(ctx context.Context, id, accountID, provider, actor string, amendment LadderAmendment) (*LadderAmendmentResult, error)

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) CancelLadderRun

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

CancelLadderRun atomically transitions a ladder_runs row from one of fromStatuses to RunStatusCancelled and stamps the canceled-by audit column with the actor's email in the same UPDATE. Returns the updated row on success, or (nil, nil) when zero rows are affected (race lost or unexpected current status). An empty actor is rejected with ErrLadderRunCancelActorRequired before any write.

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) ClaimDueLadderTranches

func (s *PostgresStore) ClaimDueLadderTranches(
	ctx context.Context,
	configID string,
	asOf time.Time,
	limit int,
	create func(ctx context.Context, tx pgx.Tx, tranche LadderTrancheDB) (executionID string, err error),
) ([]LadderTrancheDB, error)

ClaimDueLadderTranches atomically claims up to limit tranches of configID that are due (scheduled_date <= asOf) but not expired (scheduled_date > asOf - LadderFireGrace). For each claimed row, in the same transaction, create runs and must return the id of the purchase_executions row it created; the tranche is then set to fired with that execution_id. If create returns an error the whole transaction rolls back and nothing is claimed, so a claim and its execution row can never disagree after a crash. Rows locked by a concurrent claim are skipped (FOR UPDATE SKIP LOCKED), so exactly one caller wins each tranche. The whole claim runs under the config's ladder lock (taken first), including the create callback: with a caller asOf earlier than the planner's, a claim could otherwise fire a row the planner's netting read had already excluded as expired, and the row would be bought AND re-planned. A callback slower than ladderLockTimeout makes planners fail loudly.

func (*PostgresStore) ClaimMarketplaceListingSlot

func (s *PostgresStore) ClaimMarketplaceListingSlot(ctx context.Context, purchaseID, clientToken string, priceSchedule []byte) (*MarketplaceListingClaim, 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 unless the row is active, or pending without a persisted token. It stores clientToken and priceSchedule in the same statement unless an earlier attempt already stored a token, and returns the row's values as of the claim (issue #525): the handler must not trust a row read before the claim. It returns (nil, nil) when the row could not be claimed (active, pending without a token, or absent).

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) CompleteTargetMetStepTx

func (s *PostgresStore) CompleteTargetMetStepTx(ctx context.Context, tx pgx.Tx, planID string, stepNumber int, rootID, note string) error

CompleteTargetMetStepTx records a plan step whose cumulative target is already held as a completed no-op row and advances the ramp over it, all in the caller's transaction. Without a shared transaction a crash between the row and the advance would leave a settled step the ramp never moves past.

rootID names the pre-created empty root row of the step to complete in place (compare-and-set: it must still be pending/notified and empty; otherwise ErrExecutionNotInExpectedStatus), or is "" to insert a new row. The note is stored as the row's error text; the number of failed rows of unknown outcome that were counted as committed is appended to it so an advance taken on such capacity is visible.

The plan is re-read under the lock AFTER any baseline write in this transaction: that write bumps updated_at, and advancing from a plan read before it would lose the optimistic-lock check in UpdatePurchasePlanTx.

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) CountLiveExecutionsForAccount

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

CountLiveExecutionsForAccount returns the number of purchase executions referencing this cloud account whose status is not in TerminalExecutionStatuses: rows an executor, scheduler or reaper may still act on, which need the account to exist. DeleteCloudAccount detaches every other row, so these are exactly the rows that block the delete (issue #265). It includes the pending/notified rows CountPendingExecutionsForAccount counts.

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. It returns an error wrapping ErrDuplicatePendingRegistration when the partial unique index rejects a second pending registration for the same provider+external_id, and one wrapping ErrReferenceTokenCollision when reg.ReferenceToken is taken.

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. Terminal purchase executions are kept as history with cloud_account_id set to NULL; a live execution still blocks the delete through the FK (issue #265). 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. Enabled plans whose only target is this account are disabled in the same transaction (issue #264); see disableSoleAccountPlansTx.

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) ExpireExecutionAtomic

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

ExpireExecutionAtomic implements StoreInterface.

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.

The expiry is compared as Unix epoch seconds (activeExpiryEpochExpr) so the predicate can use idx_purchase_history_active_expiry (migration 000126) instead of evaluating every row ever recorded (issue #33).

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) GetExecutionFanOutShape

func (s *PostgresStore) GetExecutionFanOutShape(ctx context.Context, lineageKey string) (string, error)

GetExecutionFanOutShape returns the fan-out shape pinned for the execution lineage keyed by lineageKey, or "" when none is pinned yet.

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.

Pending rows dated in the future are the one exception to newest-first (issue #680): plan creation writes every ramp step up front as a pending row, up to 52 per plan, so about 20 plans fill MaxListLimit with steps months ahead and a plain scheduled_date DESC would cut off a pending approval due today. Those future pending steps sort after every other row, and among themselves soonest first, so the cap drops the farthest steps. No other status is affected: a notified, scheduled or in-flight row is actionable whatever its date, and terminal statuses never match the pending predicate, so their order is unchanged.

Executions with status 'completed' and an empty error are excluded: they are already represented by their purchase_history rows, so returning them would only consume the cap (the History handler skips them anyway).

func (*PostgresStore) GetGlobalConfig

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

GetGlobalConfig retrieves the global configuration.

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) GetLadderExecutionGate

func (s *PostgresStore) GetLadderExecutionGate(ctx context.Context, idempotencyKey string) (*LadderExecutionGate, error)

GetLadderExecutionGate finds the ladder tranche whose id equals an execution's idempotency key and returns its config switches and run status. The key is compared as text, so non-ladder keys (legacy execution ids, "<root>:<account>" multi-account keys, fingerprints) match nothing and return (nil, nil): "not a ladder execution". Only a real database error is an error, and the caller must fail closed on it.

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) GetPlanRampBaselineTx

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

GetPlanRampBaselineTx refuses cumulative sizing when retained history is untrusted. Fresh protected plans return nil until their baseline freezes under the plan lock.

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 = '' OR LOWER(provider) = LOWER($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. token is the raw, caller-supplied value; only its hash is stored (approval_token_hash, issue #103), so the lookup hashes it first.

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, LastCollectionStartedAt and LastCollectionPartial 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) LadderTrancheScope

func (s *PostgresStore) LadderTrancheScope(ctx context.Context, id string) (accountID, provider string, err error)

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) LinkRetryExecutionAtomic

func (s *PostgresStore) LinkRetryExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID, retryExecutionID string) (linked bool, err error)

LinkRetryExecutionAtomic stamps retry_execution_id on a failed execution row, conditional on status = 'failed' AND retry_execution_id IS NULL, without touching any other column. This replaces a full-row SavePurchaseExecutionTx upsert of a stale in-memory copy of the failed row (issue #220): that upsert both raced two concurrent retries of the same row past the in-memory already-retried guard (both would insert an approvable successor and send an approval email) and reverted any column another writer changed on the failed row in between.

Returns (true, nil) when this call won the race and wrote the linkage, (false, nil) when zero rows were affected because a concurrent retry already claimed the row (or it is no longer 'failed'), and (false, err) on a real DB error. Must be called inside the same tx that inserts the successor row, after that insert, so the FK on retry_execution_id is satisfied.

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) ListExecutionsForPlanStepTx

func (s *PostgresStore) ListExecutionsForPlanStepTx(ctx context.Context, tx pgx.Tx, planID string, stepNumber int) ([]PurchaseExecution, error)

ListExecutionsForPlanStepTx implements StoreInterface.

func (*PostgresStore) ListFiredLadderTranches

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

ListFiredLadderTranches returns fired tranches that have an execution, oldest first, for the reconciler to examine one by one.

func (*PostgresStore) ListLadderTimeline

func (s *PostgresStore) ListLadderTimeline(ctx context.Context, accountID, provider string, cursor *LadderTimelineCursor) (*LadderTimelinePage, error)

func (*PostgresStore) ListLadderTimelineRuns

func (s *PostgresStore) ListLadderTimelineRuns(ctx context.Context, accountID, provider string, cursor *LadderTimelineCursor) (*LadderTimelineRunPage, error)

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) ListPlanCommittedTx

func (s *PostgresStore) ListPlanCommittedTx(ctx context.Context, tx pgx.Tx, planID string) (map[string]PlanRampQuantity, error)

ListPlanCommittedTx returns, per recommendation identity, what the plan's executions already hold, committed or still in flight. It is the ledger the plan's next step subtracts from its cumulative target. It reads the executions themselves, never the suppressions, because a suppression expires while the capacity it covered is still held.

A row counts unless it is provably unbought: canceled (both spellings), expired, or failed with NoSubmission set. A failed row without NoSubmission is an unknown outcome and counts, as does any status this code does not know (the fail-safe direction: over-counting only under-buys). Every selected rec of a counted row counts, including a failed rec of a partially_completed row, because a rec error can be a timeout after the provider accepted it.

Within one step the root row of a fan-out, its per-account rows and a retry chain all carry the same recs, so each identity counts once per step as the largest quantity any counted row holds; steps then add up.

The caller must hold the plan's lock (LockPurchasePlanTx) in tx or the answer is advisory.

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 plans that reference at least one of the given accounts and, with filter.IncludeUnassigned, plans with zero plan_accounts rows (flagged with Unassigned=true). The no-filter case returns all plans with Unassigned=false.

func (*PostgresStore) ListRecentSubmitsTx

func (s *PostgresStore) ListRecentSubmitsTx(ctx context.Context, tx pgx.Tx, creatorID string, since time.Time) ([]PurchaseExecution, error)

ListRecentSubmitsTx serializes concurrent submits by creatorID and returns that creator's web-submitted executions scheduled at or after since, in every state in submitDedupeStatuses. The transaction-scoped advisory lock is held until tx ends, so a second submit by the same creator waits here, then reads the first one's committed row and can collapse onto it. SELECT ... FOR UPDATE could not do this: it locks only rows that already exist, so two concurrent submits both saw nothing and both inserted. The status set is wider than pending/notified because a direct execute moves its row to approved/running/completed within seconds, and a retry arriving then must still be recognized. An empty creatorID matches rows with no creator.

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) LockLadderTrancheChainTx

func (s *PostgresStore) LockLadderTrancheChainTx(ctx context.Context, tx pgx.Tx, trancheID string) ([]LadderChainNode, error)

LockLadderTrancheChainTx locks a fired tranche, follows retry_execution_id from its execution to the tail (at most LadderChainMaxDepth nodes, which also stops a retry cycle), then locks the tail execution row and re-reads it. LinkRetryExecutionAtomic updates that same row, so once this returns a retry cannot link a successor until the caller's transaction ends. The chain is returned head first. A chain that was cut short by the depth cap (a cycle hits it) or a tail that changed between the walk and the lock ends with a node whose RetryExecutionID is set; the caller must treat that as unresolved.

func (*PostgresStore) LockLadderTrancheForRetryTx

func (s *PostgresStore) LockLadderTrancheForRetryTx(ctx context.Context, tx pgx.Tx, idempotencyKey string) (status ladder.TrancheStatus, found bool, err error)

LockLadderTrancheForRetryTx locks the ladder tranche whose id equals a purchase execution's idempotency key, so Retry can decide inside its own transaction before it inserts a successor. The key is compared as text: non-ladder keys (legacy execution ids, "<root>:<account>" multi-account keys, content fingerprints) are not UUIDs and simply match nothing, which is "not a ladder execution" (found=false), not an error. Lock order is tranche first, then execution rows, the same as LockLadderTrancheChainTx.

func (*PostgresStore) LockPlanProvidersTx

func (s *PostgresStore) LockPlanProvidersTx(ctx context.Context, tx pgx.Tx, providers []string) error

LockPlanProvidersTx implements StoreInterface.LockPlanProvidersTx.

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) PinExecutionFanOutShape

func (s *PostgresStore) PinExecutionFanOutShape(ctx context.Context, lineageKey, shape string) (string, error)

PinExecutionFanOutShape records shape as the fan-out shape of the execution lineage keyed by lineageKey unless one is already recorded, and returns the recorded shape. The first writer wins and the row is never updated, so every later attempt of the lineage gets back the shape its first attempt ran with, however the plan's accounts have changed since.

func (*PostgresStore) PlanAndSaveLadderRun

func (s *PostgresStore) PlanAndSaveLadderRun(ctx context.Context, configID string, asOf time.Time, build LadderPlanBuilder) (*LadderRunDB, error)

PlanAndSaveLadderRun reads the config's in-flight commitment, lets build turn it into a run plus tranches, and persists both, all in ONE transaction under the config's ladder lock. Two overlapping runs for one config therefore cannot both plan the same gap: the second waits, then reads the first's committed tranches as in-flight. build must be pure compute. The run's ConfigID must equal configID and its StartedAt must equal asOf, which bounds both the netting read and the overdue sweep.

asOf MUST be captured before the caller reads the existing commitment (E, GetLayerStates), which happens outside this transaction. Tranches that completed after asOf are added to the in-flight figure, so one completing between the E read and the locked read is netted (counted twice if E already has it: a safe under-buy, never a miss). This relies on a tranche being marked completed only after its commitment is visible through the same source as E, and on visibility never reverting; it is safe only once the visibility adapter (executor PR5a) provides that.

func (*PostgresStore) RecoverReapedExecution

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

RecoverReapedExecution is RecoverReapedExecutionTx in its own transaction.

func (*PostgresStore) RecoverReapedExecutionTx

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

RecoverReapedExecutionTx persists a late successful outcome over a row the reaper already failed: it writes execution (whose Status must be completed or partially_completed) only if the stored row is still failed with no retry successor linked. A different status returns ErrExecutionNotInExpectedStatus; a linked successor returns ErrExecutionRetryLinked and writes nothing.

func (*PostgresStore) ReleaseMarketplaceListingClaim

func (s *PostgresStore) ReleaseMarketplaceListingClaim(ctx context.Context, purchaseID, clientToken, priorState string, keepAttempt bool) error

ReleaseMarketplaceListingClaim moves a pending row back to priorState and, unless keepAttempt, clears the persisted token and schedule. It only touches a pending row whose token is clientToken, so it never overwrites a recorded listing or the attempt of another request.

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) RescheduleAwaitingExecution

func (s *PostgresStore) RescheduleAwaitingExecution(ctx context.Context, executionID string, scheduledDate time.Time) (bool, error)

RescheduleAwaitingExecution is a conditional single-column update: the status predicate makes a lost race a benign no-op instead of an overwrite.

func (*PostgresStore) ReserveApprovedRIExchange

func (s *PostgresStore) ReserveApprovedRIExchange(ctx context.Context, id, dailyCapUSD, perExchangeCapUSD string) (string, error)

ReserveApprovedRIExchange reserves the full execution ceiling for an approved exchange whose row is already processing. It runs under the same global advisory lock as ReserveRIExchange, so manual approvals and AUTO exchanges share one headroom protocol, and it leaves the row's own amount out of the spend it checks against. The row's payment_due becomes the reserved ceiling until CompleteRIExchangeWithPayment settles the accepted amount.

func (*PostgresStore) ReserveRIExchange

func (s *PostgresStore) ReserveRIExchange(ctx context.Context, record *RIExchangeRecord, dailyCapUSD, perExchangeCapUSD string) (string, error)

ReserveRIExchange atomically records the full execution ceiling before a provider call.

func (*PostgresStore) ResolveLadderTrancheFailed

func (s *PostgresStore) ResolveLadderTrancheFailed(ctx context.Context, req LadderResolveFailed) (*LadderResolveResult, error)

ResolveLadderTrancheFailed moves a fired tranche to failed on an operator's word, releasing its netting. It locks the tranche, then the retry-chain tail, in the same order as the reconciler and Retry. It refuses while any execution of the chain may still run or has bought something, and, when a node's outcome is unknown (for example reaped), unless the operator acknowledged that and named where they checked.

func (*PostgresStore) ResolveManagedPlanRootTx

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

ResolveManagedPlanRootTx requires the plan row lock before execution locks.

func (*PostgresStore) RotatePendingApprovalToken

func (s *PostgresStore) RotatePendingApprovalToken(ctx context.Context, executionID, tokenHash string, expiresAt time.Time) (bool, error)

RotatePendingApprovalToken implements StoreInterface. The same statement that stores the emailed token promotes the row to 'notified' (a no-op for a row already there), so the status never claims a notice that was not sent.

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) SaveExecutionIfStatus

func (s *PostgresStore) SaveExecutionIfStatus(ctx context.Context, execution *PurchaseExecution, expected []string) error

SaveExecutionIfStatus is SavePurchaseExecution that only writes when the stored row is still in one of the expected statuses. A stale in-memory copy can therefore never rewrite a status another writer moved on (issue #829). A missing row returns ErrNotFound and a status mismatch returns ErrExecutionNotInExpectedStatus; neither writes anything.

func (*PostgresStore) SaveExecutionIfStatusTx

func (s *PostgresStore) SaveExecutionIfStatusTx(ctx context.Context, tx pgx.Tx, execution *PurchaseExecution, expected []string) error

SaveExecutionIfStatusTx is the tx-accepting variant of SaveExecutionIfStatus, for callers that bundle the save with other writes (for example releasing suppressions). It reuses SavePurchaseExecutionTx for the write, so the column list and the no_submission and INSERT-only rules exist in one place.

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. Scheduled tranches of the same config that are past LadderFireGrace at run.StartedAt are canceled in the same transaction, so expired rows are swept at each persisted run. 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. It is idempotent per (provider, account_id, purchase_id): a re-drive or root retry that adopts a commitment the first attempt already bought saves the same key again, and a second row would double-count spend and savings (#704). The NOT EXISTS covers rows that predate the unique index from migration 000108; the index and ON CONFLICT cover two concurrent saves. An existing row is left as is.

func (*PostgresStore) SaveRIExchangeRecord

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

SaveRIExchangeRecord saves an RI exchange record. This is the single write path for ri_exchange_history (an INSERT, never an update-in-place of an existing row's token), so it is the correct place to hash the token into approval_token_hash (issue #103): record.ApprovalToken always carries a fresh raw value freshly generated by the caller (pkg/exchange in cloud-commitments-go), never a value previously read back from this column, so hashing it here can never double-hash an already-hashed value.

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) SetExecutionRecommendationsIfEmptyTx

func (s *PostgresStore) SetExecutionRecommendationsIfEmptyTx(ctx context.Context, tx pgx.Tx, executionID string, recs []RecommendationRecord, upfront, savings float64) (bool, error)

SetExecutionRecommendationsIfEmptyTx implements StoreInterface.

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) SetPlanRampBaselineTx

func (s *PostgresStore) SetPlanRampBaselineTx(ctx context.Context, tx pgx.Tx, planID string, baseline PlanRampBaseline) (bool, error)

SetPlanRampBaselineTx freezes a baseline only while retention can prove its lineage. The caller holds the plan lock; full-row plan writes cannot promote trust.

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) SetRecommendationsCollectionPartial

func (s *PostgresStore) SetRecommendationsCollectionPartial(ctx context.Context, partial *RecommendationsCollectionPartial) error

SetRecommendationsCollectionPartial records the most recent collection's incomplete-sweep summary (issue #87), or clears it when partial is nil. Like SetRecommendationsCollectionError it is called by the scheduler AFTER UpsertRecommendations has reset the column to NULL, so a complete collection leaves it cleared and only a partial one re-sets it. It touches no other column; in particular it must not clear last_collection_started_at (see SetRecommendationsCollectionError, issue #261).

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) StampPlanNotificationSent

func (s *PostgresStore) StampPlanNotificationSent(ctx context.Context, planID string, at time.Time) error

StampPlanNotificationSent never writes a snapshot of the plan's ramp state.

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(). A transition to StatusCanceled also stamps the actor's email onto canceled_by and the legacy cancelled_by (issue #119), matching what the other cancel paths record and what History reads.

func (*PostgresStore) TransitionExecutionStatusPinned

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

TransitionExecutionStatusPinned is TransitionExecutionStatus that also requires the row's recommendations to still be empty (or not empty), as the caller saw them. A recommendation attach landing between a gate check and the claim makes the pin fail with ErrExecutionNotInExpectedStatus, so a row is never run on a snapshot different from the one that was gated.

func (*PostgresStore) TransitionExecutionStatusTokenPinned

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

TransitionExecutionStatusTokenPinned is TransitionExecutionStatus that also requires approval_token_hash to equal the hash of rawToken and the token to be unexpired, checked in the same UPDATE as the status change. The caller's earlier token check ran against a snapshot; a re-notification that rotates the token between that read and this write would otherwise still let the old token win the transition (issue #590). An empty rawToken never matches.

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. A transition to RunStatusCancelled is refused with ErrLadderRunCancelViaTransition: cancels go through CancelLadderRun, which stamps cancelled_by in the same UPDATE.

func (*PostgresStore) TransitionLadderTranche

func (s *PostgresStore) TransitionLadderTranche(ctx context.Context, id string, from, to ladder.TrancheStatus, reason string) (bool, error)

TransitionLadderTranche moves one tranche from `from` to `to` with a compare-and-set UPDATE. It returns false (and no error) when the row is missing or no longer in `from`, so a lost race is distinguishable from a DB failure. An illegal edge is an error. scheduled -> fired is not allowed here because a claim must create the execution in the same transaction; use ClaimDueLadderTranches. A scheduled tranche that already has an execution cannot be canceled. reason is stored in status_reason and is required for the failed and canceled outcomes, so a terminal tranche always says why.

func (*PostgresStore) TransitionLadderTrancheTx

func (s *PostgresStore) TransitionLadderTrancheTx(ctx context.Context, tx pgx.Tx, id string, from, to ladder.TrancheStatus, reason string) (bool, error)

TransitionLadderTrancheTx is TransitionLadderTranche inside the caller's transaction, for callers that must re-check other rows under lock first.

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 publishes the new version only after the transaction commits.

func (*PostgresStore) UpdatePurchasePlanTx

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

UpdatePurchasePlanTx rejects snapshots that lost a race with another plan writer and refuses changes to what the editor owns on a managed plan. It re-locks the row, which every caller already holds, to judge the guard on the locked version.

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, engine, term, payment_option, availability_zone, platform, tenancy) 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. Migration 000109 added availability_zone, a GENERATED column derived from payload that is never written here. Migration 000111 added the platform and tenancy generated columns the same way; the upsert arbiter is the 11-column recommendations_natural_key_az_tp_idx (issues #792, #806).

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"` // SHA-256 hex digest (approval_token_hash column), never the raw token
	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"`
	// CreatedBySystem marks a row the scheduler sweep minted with no human
	// initiator (issue #233). Server-only and insert-only: never bound from a
	// request, never rewritten by the upsert. Implies CreatedByUserID == nil
	// (CHECK in migration 000130).
	CreatedBySystem bool `json:"-" dynamodbav:"-"`
	// 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) or a run-now. NULL on rows
	// that went through the normal approval flow. Together with
	// pre_approval_skip_reason it marks a skipped approval email/wait (see ApprovedBy).
	// 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 or run-now 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 email/scheduled wait was skipped: "direct-execute permission" or
	// "run-now". 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"`
	// NoSubmission is the persisted proof that this execution failed before
	// any cloud purchase call was made (migration 000113). Only the
	// pre-submission failure paths set it; the reaper and every
	// outcome-unknown failure leave it false. It is monotone: the upsert can
	// set it but never clear it, so a stale in-memory copy cannot erase it.
	// The ladder reconciler treats a failed execution as "nothing was bought"
	// only when this is true.
	NoSubmission bool `json:"no_submission,omitempty" dynamodbav:"no_submission,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-approval states ("pending"/"notified") qualify. A "scheduled" row is not canceled: it is revoked through the revoke endpoint (CancelScheduledExecutionAtomic), which carries its own RBAC. 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. 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 = ” OR LOWER(provider) = LOWER($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 comparison ignores case, and a row whose stored provider is
	// empty (unknown) matches on account_id alone (issue #24).
	// 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:"-"`
	// CreatedBySystem propagates the execution's scheduler-created marker (#233).
	CreatedBySystem bool `json:"created_by_system,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:"-"`
	// RedriveUnsafe is true when OpsHint is a permanent refusal from
	// purchase.RedriveRefusalReason (provider has no duplicate guard), not an
	// operator-fixable misconfiguration. Computed at read time, like OpsHint.
	RedriveUnsafe bool `json:"redrive_unsafe,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.

func (*PurchaseHistoryRecord) Validate

func (r *PurchaseHistoryRecord) Validate() error

Validate rejects a record SavePurchaseHistory cannot store without losing it: one with no purchase id (see ErrEmptyPurchaseID).

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"`
	// MaxStepCommitment caps the TOTAL commitment (RecommendationRecord.TotalCommitment)
	// one ramp step may auto-purchase. nil means no cap is set, and auto-purchase of
	// recommendation-bearing steps is refused (fail closed). Always > 0 when set.
	MaxStepCommitment *float64 `json:"max_step_commitment,omitempty" dynamodbav:"max_step_commitment,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) CheckNoMultiStepSavingsPlans

func (p *PurchasePlan) CheckNoMultiStepSavingsPlans() error

CheckNoMultiStepSavingsPlans refuses an armed (enabled and auto-purchase) plan that has more than one ramp step and an enabled Savings Plans service. It is the one definition, shared by plan validation (create, update, arm) and the step precheck at attach. Single-step plans are unaffected.

func (*PurchasePlan) RequireVetoWindow

func (p *PurchasePlan) RequireVetoWindow() error

RequireVetoWindow refuses a plan that cannot give an emailed purchase at least one day before it runs. Attaching recommendations to a plan step and re-scheduling a resumed recommendation-bearing row both call it.

func (*PurchasePlan) Validate

func (p *PurchasePlan) Validate() error

Validate validates the PurchasePlan.

type PurchasePlanFilter

type PurchasePlanFilter struct {
	AccountIDs []string // nil/empty = all plans
	// IncludeUnassigned adds plans with zero plan_accounts rows to an
	// AccountIDs-filtered result. Only unrestricted callers may set it.
	IncludeUnassigned bool
}

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.

func BuildSuppressions

func BuildSuppressions(recs []RecommendationRecord, executionID string, cfg *GlobalConfig, now time.Time) []PurchaseSuppression

BuildSuppressions converts the recs on an executePurchase request into purchase_suppressions rows, one per unique 6-tuple (account, provider, service, region, resource_type, engine). Counts for duplicate tuples within the same request are summed (defensive — the UI should collapse them client-side, but the backend doesn't trust that). Providers with a grace period of 0 (feature disabled) contribute no rows.

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"` // raw when passed to SaveRIExchangeRecord (hashed there); the hash when read back
	Error              string   `json:"error,omitempty"`
	Mode               string   `json:"mode"`
	// CreatedByUserID is the UUID of the session user who submitted the exchange.
	// Always nil today: every exchange is created by the scheduler or the ladder
	// engine, and pkg/exchange.ExchangeRecord carries no creator (the 4-eyes
	// policy for creator-less rows is requireDifferentRIExchangeApprover in
	// internal/api, issue #518). 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 when no session resolves (pure email-link approval).
	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 RampAllocationMode

type RampAllocationMode string
const RampAllocationEqualCount RampAllocationMode = "equal_count"

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"`
	DatesManaged     bool               `json:"dates_managed,omitempty" dynamodbav:"dates_managed,omitempty"`
	AllocationMode   RampAllocationMode `json:"allocation_mode,omitempty" dynamodbav:"allocation_mode,omitempty"`
}

RampSchedule defines how purchases are spread over time.

func (*RampSchedule) CumulativePercent

func (r *RampSchedule) CumulativePercent(step int) float64

CumulativePercent is the share of the base (0..100) a ramp has bought once step steps are complete: step*PercentPerStep capped at 100, and 100 for an immediate schedule. It is the one definition of cumulative ramp progress.

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.

func (*RampSchedule) ValidateAllocationMode

func (r *RampSchedule) ValidateAllocationMode() error

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 {
	RequireAzurePricingMigration bool
	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:"-"`
	// AvailabilityZone is the zonal EC2 reservation's AZ name (e.g.
	// "us-east-1a"), empty for regional and non-EC2 recs. It mirrors
	// ComputeDetails.AvailabilityZone inside Details (config must not import
	// pkg/common) so the identity tuple, the rec ID and the recommendations
	// natural key can distinguish two zonal recs that differ only by AZ
	// (issue #792). The scheduler stamps it at collection time; request recs
	// echo it from the listed rec.
	AvailabilityZone string `json:"availability_zone,omitempty" dynamodbav:"availability_zone,omitempty"`
	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 (github.com/LeanerCloud/cloud-commitments-go/pkg/reporter);
	// 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"`
	SourceAccountID   *string  `json:"source_account_id,omitempty" dynamodbav:"source_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.

func FilterRecommendationsByEnabledProviders

func FilterRecommendationsByEnabledProviders(recs []RecommendationRecord, enabled []string) []RecommendationRecord

FilterRecommendationsByEnabledProviders drops recs whose Provider is absent from enabled. An empty enabled list is the permissive default (nothing is configured), so recs pass through unchanged. Shared by Opportunities and the plan-step resolver so both show and buy the same set.

func (*RecommendationRecord) ComputeIdentity

func (r *RecommendationRecord) ComputeIdentity() (ComputeIdentity, error)

ComputeIdentity reads platform and tenancy from the rec's Details blob. It mirrors the generated columns of migration 000111 (COALESCE(payload->'details'->>'platform', ”)) and is pinned to them by the shared vectors in TestComputeIdentity_Vectors: absent Details, JSON null, a missing key and a null value all yield "". Only ComputeDetails carries these keys, so the result is service-agnostic. config cannot import pkg/common, so this decodes just the two keys. A non-string value is an error.

func (*RecommendationRecord) TotalCommitment

func (r *RecommendationRecord) TotalCommitment() float64

TotalCommitment returns the recommendation's full committed spend: the upfront cost plus the recurring monthly cost over the term. UpfrontCost and MonthlyCost are totals for the rec's Count, so they sum with no per-instance scaling. Term is in years (converted with *12). A nil MonthlyCost (all-upfront commitments) or a non-positive Term contributes nothing to the recurring leg. Used for spend caps instead of UpfrontCost alone, which is $0 for no-upfront commitments (Azure monthly billing).

type RecommendationsCollectionPartial

type RecommendationsCollectionPartial struct {
	// Providers lists one entry per provider with at least one incomplete
	// sweep, in the configured provider order.
	Providers []PartialProviderSweep `json:"providers"`
}

RecommendationsCollectionPartial records that the most recent collection was incomplete without having failed: its rows were kept and stored, but some subscriptions, projects or collection scopes were never queried, so the stored recommendations under-state the real opportunity (issue #87).

It is deliberately aggregate-only. recommendations_state is one global row that every user with view:recommendations reads, while accounts are access-scoped, so it carries counts and a fixed set of reason categories -- never raw provider error text or subscription/project identifiers. The failed identifiers are named in the scheduler's WARN log for operators.

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"`
	LastCollectionPartial   *RecommendationsCollectionPartial `json:"last_collection_partial"`
}

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. LastCollectionPartial is non-nil when the most recent persisted collection kept rows from a sweep that did not cover everything it was asked to (for example 9 of 10 Azure subscriptions answered). It is cleared by the next collection whose every sweep is complete.

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.
	// Each default_* value apply changes is copied onto every service config
	// in the same transaction.
	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)
	// UpdatedAt must be the read version; a stale version returns ErrPurchasePlanConflict.
	UpdatePurchasePlan(ctx context.Context, plan *PurchasePlan) error
	StampPlanNotificationSent(ctx context.Context, planID string, at time.Time) 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)
	// LockPlanProvidersTx takes the transaction-scoped advisory lock of every
	// named provider, in sorted order, so two plan steps that resolve the same
	// provider's recommendations cannot both attach the same capacity. Lock
	// order is advisory lock, then LockPurchasePlanTx. The ladder config lock
	// (PlanAndSaveLadderRun, ClaimDueLadderTranches, AmendLadderTranche) is always taken BEFORE both:
	// ClaimDueLadderTranches's create callback may take these locks, so they
	// come after the ladder lock; code holding an execution or tranche row
	// (retry linker, reconciler) must never take the ladder lock. No cycle.
	// Duplicates are taken once; an empty list and an empty provider name are
	// errors.
	LockPlanProvidersTx(ctx context.Context, tx pgx.Tx, providers []string) 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)
	// ListExecutionsForPlanStepTx returns EVERY execution row (any status,
	// account or retry attempt) recorded for stepNumber of planID. The
	// get-or-create of a plan step reads it under LockPurchasePlanTx so any
	// existing row, settled or not, blocks minting a second root (platform#631).
	ListExecutionsForPlanStepTx(ctx context.Context, tx pgx.Tx, planID string, stepNumber int) ([]PurchaseExecution, error)
	// SetPlanRampBaselineTx freezes the plan's ramp baseline if none is set and
	// reports whether it wrote. Immutable once set; not part of
	// UpdatePurchasePlanTx, so a stale plan save cannot touch it. The caller
	// holds LockPurchasePlanTx.
	SetPlanRampBaselineTx(ctx context.Context, tx pgx.Tx, planID string, baseline PlanRampBaseline) (bool, error)
	// GetPlanRampBaselineTx returns the frozen baseline, nil when none is set.
	GetPlanRampBaselineTx(ctx context.Context, tx pgx.Tx, planID string) (PlanRampBaseline, error)
	// ListPlanCommittedTx returns what the plan's executions already hold per
	// recommendation identity (see its implementation for the status rules).
	// The caller holds LockPurchasePlanTx.
	ListPlanCommittedTx(ctx context.Context, tx pgx.Tx, planID string) (map[string]PlanRampQuantity, error)
	// CompleteTargetMetStepTx records a plan step whose cumulative target is
	// already held as a completed no-op row (new, or rootID's empty root row
	// completed in place) and advances the ramp over it in the same
	// transaction. The caller holds LockPurchasePlanTx.
	CompleteTargetMetStepTx(ctx context.Context, tx pgx.Tx, planID string, stepNumber int, rootID, note string) error
	// SetExecutionRecommendationsIfEmptyTx attaches resolved recommendations
	// and their totals to a pending/notified execution that has none yet.
	// Returns false when another resolver already attached them (or the row
	// left pending/notified); the caller re-reads instead of overwriting.
	SetExecutionRecommendationsIfEmptyTx(ctx context.Context, tx pgx.Tx, executionID string, recs []RecommendationRecord, upfront, savings float64) (bool, error)
	// ExpireExecutionAtomic flips a pending/notified execution to expired in
	// tx so its suppressions can be deleted in the same transaction. Returns
	// false when the row had already left pending/notified.
	ExpireExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID string) (bool, error)
	// UpdatePurchasePlanTx checks UpdatedAt and replaces it with the returned version.
	// The caller must discard the transaction-local plan if the transaction rolls back.
	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
	// SaveExecutionIfStatus saves only while the stored status is one of
	// expected (ErrExecutionNotInExpectedStatus otherwise), so a stale
	// in-memory copy cannot rewrite a status another writer moved on.
	SaveExecutionIfStatus(ctx context.Context, execution *PurchaseExecution, expected []string) error
	// RecoverReapedExecution persists a late completed/partially_completed
	// outcome over a reaper-failed row that has no retry successor linked
	// (ErrExecutionRetryLinked when one is).
	RecoverReapedExecution(ctx context.Context, execution *PurchaseExecution) error
	// PinExecutionFanOutShape records shape (FanOutShapeSingle or
	// FanOutShapeMulti) for the execution lineage lineageKey unless one is
	// already recorded, and returns the recorded shape (issue #97).
	PinExecutionFanOutShape(ctx context.Context, lineageKey, shape string) (string, error)
	// GetExecutionFanOutShape returns the shape pinned for lineageKey, or ""
	// when none is pinned yet (issue #97).
	GetExecutionFanOutShape(ctx context.Context, lineageKey string) (string, error)
	GetPendingExecutions(ctx context.Context) ([]PurchaseExecution, error)
	// GetExecutionsByStatuses returns executions in any of the given states,
	// newest first, capped at `limit`, except that pending rows dated in the
	// future (not-yet-due ramp steps) sort last, soonest first, so they cannot
	// push a due row past the cap (issue #680). 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)
	// CountLiveExecutionsForAccount returns the number of purchase_executions
	// referencing the given cloud account whose status is not in
	// TerminalExecutionStatuses (pending, notified, approved, running,
	// scheduled, paused, or any status not yet classified). These are the
	// rows that block DeleteCloudAccount; terminal rows are detached and kept
	// as history instead (issue #265).
	CountLiveExecutionsForAccount(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)
	// TransitionExecutionStatusPinned is TransitionExecutionStatus that also
	// requires the row's recommendations to be empty (sawEmptyRecommendations)
	// or non-empty exactly as the caller saw them, so a claim cannot land on a
	// row whose recommendations were attached after the caller's gate check. A
	// lost pin returns ErrExecutionNotInExpectedStatus like any lost CAS.
	TransitionExecutionStatusPinned(ctx context.Context, executionID string, fromStatuses []string, toStatus string, actor *string, sawEmptyRecommendations bool) (*PurchaseExecution, error)
	// TransitionExecutionStatusTokenPinned is TransitionExecutionStatus that
	// also requires the row's stored approval token hash to match rawToken and
	// to be unexpired, in the same statement as the status change, so a token
	// rotated or expired after the caller validated it cannot win the
	// transition (issue #590). A lost token pin on a row still in an allowed
	// status returns ErrApprovalTokenChanged; a lost status CAS returns
	// ErrExecutionNotInExpectedStatus as usual.
	TransitionExecutionStatusTokenPinned(ctx context.Context, executionID string, fromStatuses []string, toStatus string, actor *string, rawToken string) (*PurchaseExecution, error)
	// RescheduleAwaitingExecution sets scheduled_date on an execution that is
	// still pending or notified and touches no other column. It returns false
	// (and writes nothing) when the row has left those statuses, so a cancel or
	// approve that lands first is never overwritten.
	RescheduleAwaitingExecution(ctx context.Context, executionID string, scheduledDate time.Time) (bool, 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
	// LinkRetryExecutionAtomic stamps retry_execution_id on a failed
	// execution row without a full-row overwrite, conditional on
	// status = 'failed' AND retry_execution_id IS NULL. Replaces a
	// SavePurchaseExecutionTx upsert of a stale in-memory copy of the
	// failed row, which let two concurrent retries of the same row both
	// pass the in-memory already-retried guard and both persist an
	// approvable successor (issue #220). Returns (true, nil) when this
	// call won the race, (false, nil) when a concurrent retry already
	// claimed the row (or it left 'failed'), and (false, err) on a real
	// DB error. Must be called inside the same tx that inserts the
	// successor row, after that insert.
	LinkRetryExecutionAtomic(ctx context.Context, tx pgx.Tx, executionID, retryExecutionID string) (linked bool, err error)
	// LockLadderTrancheForRetryTx row-locks the ladder tranche whose id equals
	// idempotencyKey (matched as text; found=false for any non-ladder key) so
	// Retry can refuse a settled tranche before inserting a successor.
	LockLadderTrancheForRetryTx(ctx context.Context, tx pgx.Tx, idempotencyKey string) (status ladder.TrancheStatus, found bool, err error)
	// GetLadderExecutionGate returns the ladder config switches and run status
	// behind the tranche whose id equals idempotencyKey (matched as text), or
	// (nil, nil) for any non-ladder key. The executor rechecks it before a
	// provider call and fails closed on error.
	GetLadderExecutionGate(ctx context.Context, idempotencyKey string) (*LadderExecutionGate, error)
	// RotatePendingApprovalToken sets approval_token_hash and its expiry only
	// while the row is still pending/notified, touching no other column, so a
	// re-notification can never clobber a concurrent approve/cancel the way a
	// full-row upsert of a stale copy would. Returns false when the row left
	// pending/notified (issue #103).
	RotatePendingApprovalToken(ctx context.Context, executionID, tokenHash string, expiresAt time.Time) (rotated bool, err 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 = ” OR LOWER(provider) = LOWER($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. The
	// provider gate is case-insensitive and a row with an empty (unknown) stored
	// provider matches on account_id alone (issue #24).
	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). Recording a listing
	// resolves the attempt, so it also clears the persisted client token and
	// price schedule (issue #525).
	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 listed, and in the same UPDATE persists clientToken and
	// priceSchedule (JSON) unless an unresolved attempt already stored a token,
	// in which case that token and schedule are kept so the retry is idempotent
	// on AWS (issue #525). A pending row that holds a token is claimable again:
	// the same token makes a concurrent or crashed attempt safe to resume.
	// It returns the claimed row's values, or (nil, nil) when the row is active,
	// pending without a token, or absent (the caller maps that to a 409). The
	// caller must persist the real listing on success or call
	// ReleaseMarketplaceListingClaim on failure.
	ClaimMarketplaceListingSlot(ctx context.Context, purchaseID, clientToken string, priceSchedule []byte) (*MarketplaceListingClaim, error)

	// ReleaseMarketplaceListingClaim moves a pending row back to priorState
	// (empty for none) after a failed attempt. It only acts on a row whose
	// persisted token is clientToken, so a request never releases or drops the
	// attempt of another request. keepAttempt keeps the persisted token and
	// schedule so the retry resends them (ambiguous AWS outcome); otherwise
	// they are cleared because AWS created nothing reusable.
	ReleaseMarketplaceListingClaim(ctx context.Context, purchaseID, clientToken, priorState string, keepAttempt 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
	ReserveRIExchange(ctx context.Context, record *RIExchangeRecord, dailyCapUSD, perExchangeCapUSD string) (string, error)
	// ReserveApprovedRIExchange reserves the execution ceiling for an already
	// processing (approved) exchange under the shared daily-cap lock, excluding
	// the row's own amount, and stores the ceiling as its payment_due.
	ReserveApprovedRIExchange(ctx context.Context, id, dailyCapUSD, perExchangeCapUSD string) (string, 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
	// SetRecommendationsCollectionPartial records (or, with nil, clears) the
	// most recent collection's incomplete-sweep summary. Upsert/Replace
	// clear it, so a later complete collection drops the marker.
	SetRecommendationsCollectionPartial(ctx context.Context, partial *RecommendationsCollectionPartial) 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

	// SaveExecutionIfStatusTx and RecoverReapedExecutionTx are the
	// tx-accepting variants of SaveExecutionIfStatus and RecoverReapedExecution.
	SaveExecutionIfStatusTx(ctx context.Context, tx pgx.Tx, execution *PurchaseExecution, expected []string) error
	RecoverReapedExecutionTx(ctx context.Context, tx pgx.Tx, execution *PurchaseExecution) error

	// GetPendingExecutionsTx is the tx-accepting variant of
	// GetPendingExecutions. It row-locks the pending rows it returns; it does
	// not serialize concurrent inserts (see ListRecentSubmitsTx).
	GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ([]PurchaseExecution, error)

	// ListRecentSubmitsTx takes a per-creator transaction-scoped lock and
	// returns the creator's web-submitted executions scheduled at or after
	// since that are pending, in flight or bought. executePurchase's
	// duplicate guard reads it inside the WithTx block that inserts the new
	// execution, so concurrent identical submits serialize and a retry
	// that arrives after a direct execute left "pending" is still seen.
	ListRecentSubmitsTx(ctx context.Context, tx pgx.Tx, creatorID string, since time.Time) ([]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. A transition to
	// RunStatusCancelled is refused (ErrLadderRunCancelViaTransition).
	//
	// CancelLadderRun is the only way to cancel a ladder run: a CAS UPDATE
	// from one of fromStatuses to cancelled that stamps cancelled_by with the
	// actor's email in the same statement. An empty actor is refused
	// (ErrLadderRunCancelActorRequired); (nil, nil) means the CAS lost.
	// PlanAndSaveLadderRun is the ladder planning persist: in ONE READ COMMITTED
	// transaction under the config's advisory lock it reads the in-flight
	// commitment (scheduled rows within LadderFireGrace plus fired rows), calls
	// build with it, then inserts the run, sweeps overdue tranches and inserts
	// the new ones (append-only, L5). Overlapping runs for one config serialize:
	// the second nets the first's committed rows. build must be pure compute
	// (no I/O); run.StartedAt must equal asOf. asOf MUST be captured before the
	// caller reads the existing commitment (E): tranches completed after asOf
	// are added to the in-flight figure, so one that completes between the E
	// read and the locked read is netted, never missed (a double count is a
	// safe under-buy). This holds only if a tranche is marked completed after
	// its commitment is visible through E's source (executor PR5a). The lock is
	// also taken first by ClaimDueLadderTranches.
	PlanAndSaveLadderRun(ctx context.Context, configID string, asOf time.Time, build LadderPlanBuilder) (*LadderRunDB, error)
	SaveLadderRun(ctx context.Context, run *LadderRunDB) (*LadderRunDB, 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)
	CancelLadderRun(ctx context.Context, id string, fromStatuses []ladder.RunStatus, cancelledBy string) (*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