|
| 1 | +package ladder |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "math/big" |
| 6 | + "strings" |
| 7 | + "time" |
| 8 | + "unicode" |
| 9 | +) |
| 10 | + |
| 11 | +// PlannedAction is a single commitment action proposed by the ladder engine. |
| 12 | +// |
| 13 | +// Every action must carry a non-empty Rationale for audit and approval-email |
| 14 | +// readability -- automated money-path decisions must be explained so the |
| 15 | +// human approver (or the audit log) can understand why each action was chosen. |
| 16 | +type PlannedAction struct { |
| 17 | + Action ActionType |
| 18 | + Layer LayerType |
| 19 | + // AmountUSDPerHour is the hourly commitment delta. It must be non-nil and |
| 20 | + // positive for ActionPurchase, and must be nil for ActionHold and |
| 21 | + // ActionReshape (whose financial impact is implicit in the underlying |
| 22 | + // exchange operation). |
| 23 | + AmountUSDPerHour *big.Rat |
| 24 | + // Term is the commitment term. Must be a valid Term for purchase |
| 25 | + // actions; empty for hold and reshape. |
| 26 | + Term Term |
| 27 | + // PaymentOption is the payment structure. Must be a valid PaymentOption |
| 28 | + // for purchase actions; empty for hold and reshape. |
| 29 | + PaymentOption PaymentOption |
| 30 | + // Rationale is a human-readable explanation of why this action was chosen. |
| 31 | + // Must be non-empty for all action types. |
| 32 | + Rationale string |
| 33 | + // DataSources lists the data feeds (Cost Explorer, CloudWatch, etc.) used |
| 34 | + // to derive this action. Aids auditability. |
| 35 | + DataSources []string |
| 36 | +} |
| 37 | + |
| 38 | +// Validate checks that the action is self-consistent: |
| 39 | +// - action type and layer type must be recognized |
| 40 | +// - rationale must be non-empty |
| 41 | +// - purchase actions require a positive AmountUSDPerHour and valid Term |
| 42 | +// and PaymentOption enum values (money-shaping fields must be present) |
| 43 | +// - hold and reshape actions require a nil AmountUSDPerHour and empty |
| 44 | +// Term and PaymentOption |
| 45 | +func (a *PlannedAction) Validate() error { |
| 46 | + if err := a.Action.Validate(); err != nil { |
| 47 | + return fmt.Errorf("action: %w", err) |
| 48 | + } |
| 49 | + if err := a.Layer.Validate(); err != nil { |
| 50 | + return fmt.Errorf("layer: %w", err) |
| 51 | + } |
| 52 | + if a.Rationale == "" { |
| 53 | + return fmt.Errorf("rationale is required (money-path auditability)") |
| 54 | + } |
| 55 | + switch a.Action { |
| 56 | + case ActionPurchase: |
| 57 | + return a.validatePurchaseFields() |
| 58 | + case ActionHold, ActionReshape: |
| 59 | + if a.AmountUSDPerHour != nil { |
| 60 | + return fmt.Errorf("%s action requires nil AmountUSDPerHour, got %s", |
| 61 | + a.Action, a.AmountUSDPerHour.RatString()) |
| 62 | + } |
| 63 | + if a.Term != "" || a.PaymentOption != "" { |
| 64 | + return fmt.Errorf("%s action requires empty Term and PaymentOption", a.Action) |
| 65 | + } |
| 66 | + } |
| 67 | + return nil |
| 68 | +} |
| 69 | + |
| 70 | +// validatePurchaseFields checks the money-shaping fields required for a |
| 71 | +// purchase action. Split out of Validate to keep each function's cyclomatic |
| 72 | +// complexity within the repo limit. |
| 73 | +func (a *PlannedAction) validatePurchaseFields() error { |
| 74 | + if a.AmountUSDPerHour == nil { |
| 75 | + return fmt.Errorf("purchase action requires a non-nil AmountUSDPerHour") |
| 76 | + } |
| 77 | + if a.AmountUSDPerHour.Sign() <= 0 { |
| 78 | + return fmt.Errorf("purchase action requires a positive AmountUSDPerHour, got %s", |
| 79 | + a.AmountUSDPerHour.RatString()) |
| 80 | + } |
| 81 | + // Term and PaymentOption shape the money committed; a purchase with |
| 82 | + // either missing or unrecognized would silently default at the provider |
| 83 | + // boundary. Validate against the typed enums. |
| 84 | + if err := a.Term.Validate(); err != nil { |
| 85 | + return fmt.Errorf("purchase action term: %w", err) |
| 86 | + } |
| 87 | + if err := a.PaymentOption.Validate(); err != nil { |
| 88 | + return fmt.Errorf("purchase action payment option: %w", err) |
| 89 | + } |
| 90 | + return nil |
| 91 | +} |
| 92 | + |
| 93 | +// LadderPlan is a complete, validated commitment plan for one scope and run. |
| 94 | +// It captures the baseline, the monetary gap, and the ordered list of actions |
| 95 | +// the engine proposes to close that gap. |
| 96 | +// |
| 97 | +//nolint:revive // Ladder* prefix is the spec-mandated public name (issue #1334); matches pkg/exchange's Exchange* convention. |
| 98 | +type LadderPlan struct { |
| 99 | + Scope Scope |
| 100 | + GeneratedAt time.Time |
| 101 | + // TargetUSDPerHour is the hourly commitment target derived from |
| 102 | + // Baseline * TargetCoveragePct. nil means it could not be computed. |
| 103 | + TargetUSDPerHour *big.Rat |
| 104 | + // ExistingUSDPerHour is the sum of hourly amortized costs across all |
| 105 | + // active commitment layers. nil means it could not be measured. |
| 106 | + ExistingUSDPerHour *big.Rat |
| 107 | + // GapUSDPerHour is TargetUSDPerHour - ExistingUSDPerHour. nil when either |
| 108 | + // input is nil. |
| 109 | + GapUSDPerHour *big.Rat |
| 110 | + Actions []PlannedAction |
| 111 | + Baseline UsageBaseline |
| 112 | +} |
| 113 | + |
| 114 | +// Validate checks that the plan is self-consistent: a valid scope, a set |
| 115 | +// generation timestamp, and all PlannedActions valid. |
| 116 | +func (p *LadderPlan) Validate() error { |
| 117 | + if err := p.Scope.Validate(); err != nil { |
| 118 | + return fmt.Errorf("scope: %w", err) |
| 119 | + } |
| 120 | + // A zero GeneratedAt would corrupt approval-expiry and audit ordering |
| 121 | + // downstream; fail loud like Tranche.FireAfter. |
| 122 | + if p.GeneratedAt.IsZero() { |
| 123 | + return fmt.Errorf("generated_at must be set") |
| 124 | + } |
| 125 | + for i, a := range p.Actions { |
| 126 | + if err := a.Validate(); err != nil { |
| 127 | + return fmt.Errorf("action[%d]: %w", i, err) |
| 128 | + } |
| 129 | + } |
| 130 | + return nil |
| 131 | +} |
| 132 | + |
| 133 | +// sanitizeLine strips control characters (including \n and \r) from a value |
| 134 | +// interpolated into a single Explain line. Without this, a crafted field |
| 135 | +// (e.g. a Rationale containing "\n 2. fake action") could spoof extra lines |
| 136 | +// in the approval email body. |
| 137 | +func sanitizeLine(s string) string { |
| 138 | + return strings.Map(func(r rune) rune { |
| 139 | + if unicode.IsControl(r) { |
| 140 | + return -1 |
| 141 | + } |
| 142 | + return r |
| 143 | + }, s) |
| 144 | +} |
| 145 | + |
| 146 | +// formatUSDPerHour renders a *big.Rat as a fixed 2-decimal USD/hr string. |
| 147 | +// nil is rendered as "unknown" -- never as "$0.00" -- to unambiguously |
| 148 | +// distinguish absent data from a genuine zero commitment (project rule: |
| 149 | +// absent numbers are nil, not zero). |
| 150 | +// |
| 151 | +// FloatString(2) formats the exact rational with half-away-from-zero |
| 152 | +// rounding, avoiding the float64 conversion step (e.g. 199/200 renders as |
| 153 | +// $1.00/hr, not $0.99/hr). |
| 154 | +func formatUSDPerHour(v *big.Rat) string { |
| 155 | + if v == nil { |
| 156 | + return "unknown" |
| 157 | + } |
| 158 | + return fmt.Sprintf("$%s/hr", v.FloatString(2)) |
| 159 | +} |
| 160 | + |
| 161 | +// Explain returns a deterministic, human-readable multi-line summary of the |
| 162 | +// plan. The output is suitable for use as an approval email body and is |
| 163 | +// designed to be read by a non-technical budget owner. |
| 164 | +// |
| 165 | +// Layout: |
| 166 | +// 1. Scope header and generation timestamp |
| 167 | +// 2. Baseline parameters (lookback window, percentile) |
| 168 | +// 3. Target / existing / gap hourly rates |
| 169 | +// 4. Numbered action list (or "none") |
| 170 | +// 5. "Data sources:" line aggregating the actions' DataSources (omitted |
| 171 | +// when no action carries any) |
| 172 | +// |
| 173 | +// Every interpolated free-form field is passed through sanitizeLine so a |
| 174 | +// crafted value cannot spoof additional lines in the email body. |
| 175 | +func (p *LadderPlan) Explain() string { |
| 176 | + var b strings.Builder |
| 177 | + |
| 178 | + fmt.Fprintf(&b, "Scope: provider=%s account=%s\n", p.Scope.Provider, sanitizeLine(p.Scope.AccountID)) |
| 179 | + fmt.Fprintf(&b, "Generated: %s\n", p.GeneratedAt.UTC().Format(time.RFC3339)) |
| 180 | + fmt.Fprintf(&b, "Baseline: lookback=%dd percentile=%g\n", |
| 181 | + p.Baseline.LookbackDays, p.Baseline.Percentile) |
| 182 | + fmt.Fprintf(&b, "Target: %s\n", formatUSDPerHour(p.TargetUSDPerHour)) |
| 183 | + fmt.Fprintf(&b, "Existing: %s\n", formatUSDPerHour(p.ExistingUSDPerHour)) |
| 184 | + fmt.Fprintf(&b, "Gap: %s\n", formatUSDPerHour(p.GapUSDPerHour)) |
| 185 | + |
| 186 | + if len(p.Actions) == 0 { |
| 187 | + fmt.Fprintf(&b, "Actions: none\n") |
| 188 | + return b.String() |
| 189 | + } |
| 190 | + |
| 191 | + fmt.Fprintf(&b, "Actions (%d):\n", len(p.Actions)) |
| 192 | + for i, a := range p.Actions { |
| 193 | + switch a.Action { |
| 194 | + case ActionPurchase: |
| 195 | + fmt.Fprintf(&b, " %d. %s %s %s term=%s payment=%s -- %s\n", |
| 196 | + i+1, a.Action, a.Layer, formatUSDPerHour(a.AmountUSDPerHour), |
| 197 | + sanitizeLine(string(a.Term)), sanitizeLine(string(a.PaymentOption)), |
| 198 | + sanitizeLine(a.Rationale)) |
| 199 | + default: |
| 200 | + fmt.Fprintf(&b, " %d. %s %s -- %s\n", |
| 201 | + i+1, a.Action, a.Layer, sanitizeLine(a.Rationale)) |
| 202 | + } |
| 203 | + } |
| 204 | + if sources := collectDataSources(p.Actions); len(sources) > 0 { |
| 205 | + fmt.Fprintf(&b, "Data sources: %s\n", strings.Join(sources, ", ")) |
| 206 | + } |
| 207 | + return b.String() |
| 208 | +} |
| 209 | + |
| 210 | +// collectDataSources aggregates the DataSources of all actions, sanitized |
| 211 | +// and deduplicated, preserving first-seen stored order so the output stays |
| 212 | +// deterministic for a given plan. |
| 213 | +func collectDataSources(actions []PlannedAction) []string { |
| 214 | + var sources []string |
| 215 | + seen := make(map[string]bool) |
| 216 | + for _, a := range actions { |
| 217 | + for _, s := range a.DataSources { |
| 218 | + s = sanitizeLine(s) |
| 219 | + if seen[s] { |
| 220 | + continue |
| 221 | + } |
| 222 | + seen[s] = true |
| 223 | + sources = append(sources, s) |
| 224 | + } |
| 225 | + } |
| 226 | + return sources |
| 227 | +} |
0 commit comments