From 4644bf2b458e13e72db724db602dfbd236e8d756 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 09:41:54 -0700 Subject: [PATCH 01/12] PCTL-18: define cost driver and observation contracts --- docs/contracts/cost-contract-v1.md | 128 ++++++++ pkg/contracts/cost/v1/legacy.go | 131 ++++++++ pkg/contracts/cost/v1/legacy_test.go | 77 +++++ pkg/contracts/cost/v1/types.go | 131 ++++++++ pkg/contracts/cost/v1/validate.go | 288 ++++++++++++++++++ pkg/contracts/cost/v1/validate_test.go | 150 +++++++++ schemas/cost-contract/v1/common.schema.json | 115 +++++++ .../cost-contract/v1/cost-driver.schema.json | 47 +++ .../v1/cost-observation.schema.json | 65 ++++ .../v1/legacy_valid_config_mapping.json | 26 ++ .../v1/upstash_idle_polling.json | 121 ++++++++ 11 files changed, 1279 insertions(+) create mode 100644 docs/contracts/cost-contract-v1.md create mode 100644 pkg/contracts/cost/v1/legacy.go create mode 100644 pkg/contracts/cost/v1/legacy_test.go create mode 100644 pkg/contracts/cost/v1/types.go create mode 100644 pkg/contracts/cost/v1/validate.go create mode 100644 pkg/contracts/cost/v1/validate_test.go create mode 100644 schemas/cost-contract/v1/common.schema.json create mode 100644 schemas/cost-contract/v1/cost-driver.schema.json create mode 100644 schemas/cost-contract/v1/cost-observation.schema.json create mode 100644 test/fixtures/cost_contract/v1/legacy_valid_config_mapping.json create mode 100644 test/fixtures/cost_contract/v1/upstash_idle_polling.json diff --git a/docs/contracts/cost-contract-v1.md b/docs/contracts/cost-contract-v1.md new file mode 100644 index 0000000..fc50377 --- /dev/null +++ b/docs/contracts/cost-contract-v1.md @@ -0,0 +1,128 @@ +# Cost Driver and Observation Contract v1 + +Status: PCTL-18 candidate + +Schema version: `profitctl.cost/v1` + +## Purpose + +This contract lets ProfitCtl compare forecast drivers with cost observations +without treating provenance, measurement, billing, or user input as +interchangeable evidence. + +Contract code lives in `pkg/contracts/cost/v1`. Structural JSON Schemas live in +`schemas/cost-contract/v1`. Go `Validate` methods enforce cross-field rules that +JSON Schema cannot express reliably, including matching units and currencies, +ordered time windows, and evidence/source compatibility. + +This version is additive. It does not change `simulate`, current scenario math, +CLI output, or provider access. + +## Driver Taxonomy + +| Kind | Meaning | Example | +| --- | --- | --- | +| `fixed` | Commitment independent of workload volume | One platform commitment per month | +| `variable` | Quantity scaling with another unit | 10,000 requests per user | +| `cadence` | Repeated work over time | 1,386,000 polling commands per month | +| `concurrency` | Simultaneously active capacity | Four workers | +| `uptime` | Active duration over a window | 720 worker-hours per month | + +Every driver requires: + +- canonical quantity unit; +- explicit RFC3339 start and end; +- price amount, ISO 4217 currency, and price basis; +- workload dimension; +- evidence class, measurement status, source identity, capture time, + confidence, and rationale. + +Units use lowercase canonical identifiers such as `request`, `command`, +`token`, `gibibyte`, `user`, `worker`, `hour`, or `month`. Ambiguous storage +units such as `GB`/`gb`, free-text ratios, and generic `unit`/`units` fail +validation. + +## Observation Envelope + +An observation binds one or more driver IDs to: + +- one explicit time window; +- quantity and unit; +- unit price, price basis, and currency; +- total cost and currency; +- provider, service, region, tier, and workload dimensions where applicable; +- separate evidence for quantity, unit price, and total cost. + +Claim-level evidence matters. Telemetry may own quantity while a provider +catalog owns a planning rate and an invoice owns billed total. One source must +not silently stand in for all three claims. + +## Evidence Semantics + +Evidence kinds: + +- `predicted`: forecast or derived planning value; +- `observed`: measured or explicitly synthetic observation; +- `billed`: billed value backed by invoice evidence; +- `user_supplied`: declared operator value. + +Measurement status is separate: + +- `measured`: allowed only for telemetry, runtime-ledger, or invoice sources; +- `synthetic`: allowed only for a named synthetic fixture; +- `derived`: calculated from other explicit claims; +- `declared`: asserted assumption. + +Rules: + +- provenance alone never proves measurement; +- `billed` requires invoice source; +- `user_supplied` requires user-supplied source; +- synthetic observations stay visibly synthetic; +- missing source identity, capture time, confidence rationale, units, time + windows, or currency fails closed. + +## Current Scenario Compatibility + +Legacy scenarios remain authoritative for current simulation behavior. +`MapLegacyCosts` creates additive v1 driver views and does not mutate legacy +inputs or the cost engine. + +Mapping rules: + +| Legacy field | v1 mapping | +| --- | --- | +| `fixed_costs[].amount` | unit price per `commitment` | +| `fixed_costs[].period` | driver `per` unit: `day`, `month`, or `year` | +| `variable_costs[].cost_per_unit` | unit-price amount | +| `variable_costs[].units_per_user` | quantity per one `user` | +| absent variable unit | caller must supply explicit name-to-unit mapping | +| absent scenario currency/window | caller must supply both | +| absent source | low-confidence `legacy_scenario`; never measured | + +The compatibility fixture +`test/fixtures/cost_contract/v1/legacy_valid_config_mapping.json` maps the +current `valid_config.yml` scenario. Tests prove mapped drivers validate and +legacy calculated totals remain unchanged. + +## Upstash Idle-Polling Fixture + +`test/fixtures/cost_contract/v1/upstash_idle_polling.json` provides one +forecast-to-observation mapping with 1,386,000 synthetic polling commands. + +The fixture is not live Upstash evidence: + +- quantity is marked `observed` plus `synthetic`; +- rate is marked `user_supplied` plus `declared`; +- total is marked `predicted` plus `derived`; +- confidence remains low; +- fixture text explicitly rejects a current-price or billing claim. + +No provider calls, credentials, or live data are required. + +## Versioning + +Breaking field, enum, or semantic changes require a new schema namespace such +as `profitctl.cost/v2` and new fixture directory. Additive v1 changes must keep +existing v1 fixtures valid. Current scenario behavior changes require separate +versioned product authority. diff --git a/pkg/contracts/cost/v1/legacy.go b/pkg/contracts/cost/v1/legacy.go new file mode 100644 index 0000000..f4b1e26 --- /dev/null +++ b/pkg/contracts/cost/v1/legacy.go @@ -0,0 +1,131 @@ +package v1 + +import ( + "errors" + "fmt" + "strings" + + "github.com/IntelIP/ProfitCtl/pkg/types" +) + +type LegacyMapping struct { + ArtifactIdentity string `json:"artifact_identity" yaml:"artifact_identity"` + CapturedAt string `json:"captured_at" yaml:"captured_at"` + Currency string `json:"currency" yaml:"currency"` + Window TimeWindow `json:"window" yaml:"window"` + VariableUnits map[string]string `json:"variable_units" yaml:"variable_units"` +} + +// MapLegacyCosts maps current scenario inputs into v1 drivers without changing +// simulator behavior. Legacy variable costs have no unit field, so callers must +// provide an explicit name-to-unit mapping. +func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapping LegacyMapping) ([]CostDriver, error) { + if strings.TrimSpace(mapping.ArtifactIdentity) == "" { + return nil, errors.New("artifact_identity is required") + } + if !validCaptureTime(mapping.CapturedAt) { + return nil, errors.New("captured_at must be an RFC3339 timestamp or ISO date") + } + if err := (Money{Currency: mapping.Currency}).Validate(); err != nil { + return nil, err + } + if err := mapping.Window.Validate(); err != nil { + return nil, fmt.Errorf("window: %w", err) + } + + drivers := make([]CostDriver, 0, len(fixed)+len(variable)) + for i, cost := range fixed { + periodUnit, err := legacyPeriodUnit(cost.Period) + if err != nil { + return nil, fmt.Errorf("fixed cost %q: %w", cost.Name, err) + } + driver := CostDriver{ + SchemaVersion: SchemaVersion, + ID: fmt.Sprintf("legacy-fixed-%d", i+1), + Name: cost.Name, + Kind: DriverFixed, + Quantity: Quantity{Value: 1, Unit: "commitment"}, + Per: &Quantity{Value: 1, Unit: periodUnit}, + UnitPrice: UnitPrice{ + Amount: Money{Amount: cost.Amount, Currency: mapping.Currency}, + Per: Quantity{Value: 1, Unit: "commitment"}, + }, + Window: mapping.Window, + Dimensions: Dimensions{Workload: cost.Name}, + Evidence: legacyEvidence(cost.Source, mapping), + } + if err := driver.Validate(); err != nil { + return nil, fmt.Errorf("fixed cost %q: %w", cost.Name, err) + } + drivers = append(drivers, driver) + } + + for i, cost := range variable { + unit := mapping.VariableUnits[cost.Name] + if err := validateUnit(unit); err != nil { + return nil, fmt.Errorf("variable cost %q unit: %w", cost.Name, err) + } + driver := CostDriver{ + SchemaVersion: SchemaVersion, + ID: fmt.Sprintf("legacy-variable-%d", i+1), + Name: cost.Name, + Kind: DriverVariable, + Quantity: Quantity{Value: cost.UnitsPerUser, Unit: unit}, + Per: &Quantity{Value: 1, Unit: "user"}, + UnitPrice: UnitPrice{ + Amount: Money{Amount: cost.CostPerUnit, Currency: mapping.Currency}, + Per: Quantity{Value: 1, Unit: unit}, + }, + Window: mapping.Window, + Dimensions: Dimensions{Workload: cost.Name}, + Evidence: legacyEvidence(cost.Source, mapping), + } + if err := driver.Validate(); err != nil { + return nil, fmt.Errorf("variable cost %q: %w", cost.Name, err) + } + drivers = append(drivers, driver) + } + return drivers, nil +} + +func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { + evidence := Evidence{ + Kind: EvidencePredicted, + Measurement: MeasurementDeclared, + Source: SourceReference{ + Type: SourceLegacyScenario, + ArtifactIdentity: mapping.ArtifactIdentity, + CapturedAt: mapping.CapturedAt, + }, + Confidence: ConfidenceLow, + ConfidenceRationale: "Legacy scenario assumption mapped without claiming runtime measurement.", + } + if source == nil { + return evidence + } + + evidence.Source.Type = SourceType(source.Type) + evidence.Source.URL = source.URL + evidence.Source.CapturedAt = source.CapturedAt + if evidence.Source.CapturedAt == "" { + evidence.Source.CapturedAt = mapping.CapturedAt + } + evidence.Confidence = Confidence(source.Confidence) + if strings.TrimSpace(source.Note) != "" { + evidence.ConfidenceRationale = source.Note + } + return evidence +} + +func legacyPeriodUnit(period types.CostPeriod) (string, error) { + switch period { + case types.PeriodDaily: + return "day", nil + case types.PeriodMonthly: + return "month", nil + case types.PeriodYearly: + return "year", nil + default: + return "", fmt.Errorf("unsupported period %q", period) + } +} diff --git a/pkg/contracts/cost/v1/legacy_test.go b/pkg/contracts/cost/v1/legacy_test.go new file mode 100644 index 0000000..ab91a55 --- /dev/null +++ b/pkg/contracts/cost/v1/legacy_test.go @@ -0,0 +1,77 @@ +package v1_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/IntelIP/ProfitCtl/internal/config" + "github.com/IntelIP/ProfitCtl/internal/cost" + v1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + "github.com/stretchr/testify/require" +) + +func TestLegacyScenarioMapsForwardWithoutChangingCosts(t *testing.T) { + var fixture struct { + SourceScenario string `json:"source_scenario"` + Mapping v1.LegacyMapping `json:"mapping"` + Expected []struct { + Name string `json:"name"` + Kind v1.DriverKind `json:"kind"` + Unit string `json:"unit"` + PerUnit string `json:"per_unit"` + } `json:"expected"` + } + decodeFixture(t, "legacy_valid_config_mapping.json", &fixture) + + root := repoRoot(t) + cfg, err := config.ParseConfig(filepath.Join(root, fixture.SourceScenario)) + require.NoError(t, err) + before := cost.NewCostEngine(cfg).CalculateTotalCosts(100, 1) + + drivers, err := v1.MapLegacyCosts(cfg.FixedCosts, cfg.VariableCosts, fixture.Mapping) + require.NoError(t, err) + require.Len(t, drivers, len(fixture.Expected)) + + for i, expected := range fixture.Expected { + require.NoError(t, drivers[i].Validate()) + require.Equal(t, expected.Name, drivers[i].Name) + require.Equal(t, expected.Kind, drivers[i].Kind) + require.Equal(t, expected.Unit, drivers[i].Quantity.Unit) + require.NotNil(t, drivers[i].Per) + require.Equal(t, expected.PerUnit, drivers[i].Per.Unit) + } + + after := cost.NewCostEngine(cfg).CalculateTotalCosts(100, 1) + require.Equal(t, before, after) +} + +func TestLegacyMappingFailsClosedWithoutVariableUnit(t *testing.T) { + data, err := os.ReadFile(filepath.Join(repoRoot(t), "test", "fixtures", "valid_config.yml")) + require.NoError(t, err) + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "profit.yml") + require.NoError(t, os.WriteFile(configPath, data, 0o600)) + cfg, err := config.ParseConfig(configPath) + require.NoError(t, err) + + _, err = v1.MapLegacyCosts(cfg.FixedCosts, cfg.VariableCosts, v1.LegacyMapping{ + ArtifactIdentity: "profit.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + }) + require.ErrorContains(t, err, "variable cost") + require.ErrorContains(t, err, "unit") +} + +func TestCompatibilityFixtureIsStableJSON(t *testing.T) { + path := filepath.Join(repoRoot(t), "test", "fixtures", "cost_contract", "v1", "legacy_valid_config_mapping.json") + data, err := os.ReadFile(path) + require.NoError(t, err) + require.True(t, json.Valid(data)) +} diff --git a/pkg/contracts/cost/v1/types.go b/pkg/contracts/cost/v1/types.go new file mode 100644 index 0000000..4151607 --- /dev/null +++ b/pkg/contracts/cost/v1/types.go @@ -0,0 +1,131 @@ +// Package v1 defines ProfitCtl's first versioned cost-driver and +// cost-observation contract. +package v1 + +const SchemaVersion = "profitctl.cost/v1" + +type DriverKind string + +const ( + DriverFixed DriverKind = "fixed" + DriverVariable DriverKind = "variable" + DriverCadence DriverKind = "cadence" + DriverConcurrency DriverKind = "concurrency" + DriverUptime DriverKind = "uptime" +) + +type EvidenceKind string + +const ( + EvidencePredicted EvidenceKind = "predicted" + EvidenceObserved EvidenceKind = "observed" + EvidenceBilled EvidenceKind = "billed" + EvidenceUserSupplied EvidenceKind = "user_supplied" +) + +type MeasurementKind string + +const ( + MeasurementMeasured MeasurementKind = "measured" + MeasurementSynthetic MeasurementKind = "synthetic" + MeasurementDerived MeasurementKind = "derived" + MeasurementDeclared MeasurementKind = "declared" +) + +type SourceType string + +const ( + SourceTemplate SourceType = "template" + SourceUserSupplied SourceType = "user_supplied" + SourceRepoDetected SourceType = "repo_detected" + SourceTelemetry SourceType = "telemetry" + SourceRuntimeLedger SourceType = "runtime_ledger" + SourceInvoice SourceType = "invoice" + SourceProviderCatalog SourceType = "provider_catalog" + SourceSyntheticFixture SourceType = "synthetic_fixture" + SourceLegacyScenario SourceType = "legacy_scenario" +) + +type Confidence string + +const ( + ConfidenceLow Confidence = "low" + ConfidenceMedium Confidence = "medium" + ConfidenceHigh Confidence = "high" +) + +type Quantity struct { + Value float64 `json:"value" yaml:"value"` + Unit string `json:"unit" yaml:"unit"` +} + +type Money struct { + Amount float64 `json:"amount" yaml:"amount"` + Currency string `json:"currency" yaml:"currency"` +} + +type UnitPrice struct { + Amount Money `json:"amount" yaml:"amount"` + Per Quantity `json:"per" yaml:"per"` +} + +type TimeWindow struct { + Start string `json:"start" yaml:"start"` + End string `json:"end" yaml:"end"` +} + +type Dimensions struct { + Provider string `json:"provider,omitempty" yaml:"provider,omitempty"` + Service string `json:"service,omitempty" yaml:"service,omitempty"` + Region string `json:"region,omitempty" yaml:"region,omitempty"` + Tier string `json:"tier,omitempty" yaml:"tier,omitempty"` + Workload string `json:"workload" yaml:"workload"` +} + +type SourceReference struct { + Type SourceType `json:"type" yaml:"type"` + ArtifactIdentity string `json:"artifact_identity,omitempty" yaml:"artifact_identity,omitempty"` + URL string `json:"url,omitempty" yaml:"url,omitempty"` + CapturedAt string `json:"captured_at" yaml:"captured_at"` +} + +// Evidence classifies a claim separately from its provenance. A source label +// alone never establishes that a value was measured. +type Evidence struct { + Kind EvidenceKind `json:"kind" yaml:"kind"` + Measurement MeasurementKind `json:"measurement" yaml:"measurement"` + Source SourceReference `json:"source" yaml:"source"` + Confidence Confidence `json:"confidence" yaml:"confidence"` + ConfidenceRationale string `json:"confidence_rationale" yaml:"confidence_rationale"` +} + +type CostDriver struct { + SchemaVersion string `json:"schema_version" yaml:"schema_version"` + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + Kind DriverKind `json:"kind" yaml:"kind"` + Quantity Quantity `json:"quantity" yaml:"quantity"` + Per *Quantity `json:"per,omitempty" yaml:"per,omitempty"` + UnitPrice UnitPrice `json:"unit_price" yaml:"unit_price"` + Window TimeWindow `json:"window" yaml:"window"` + Dimensions Dimensions `json:"dimensions" yaml:"dimensions"` + Evidence Evidence `json:"evidence" yaml:"evidence"` +} + +type ClaimEvidence struct { + Quantity Evidence `json:"quantity" yaml:"quantity"` + UnitPrice Evidence `json:"unit_price" yaml:"unit_price"` + TotalCost Evidence `json:"total_cost" yaml:"total_cost"` +} + +type CostObservation struct { + SchemaVersion string `json:"schema_version" yaml:"schema_version"` + ID string `json:"id" yaml:"id"` + DriverIDs []string `json:"driver_ids" yaml:"driver_ids"` + Window TimeWindow `json:"window" yaml:"window"` + Quantity Quantity `json:"quantity" yaml:"quantity"` + UnitPrice UnitPrice `json:"unit_price" yaml:"unit_price"` + TotalCost Money `json:"total_cost" yaml:"total_cost"` + Dimensions Dimensions `json:"dimensions" yaml:"dimensions"` + Evidence ClaimEvidence `json:"evidence" yaml:"evidence"` +} diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go new file mode 100644 index 0000000..d7e4f15 --- /dev/null +++ b/pkg/contracts/cost/v1/validate.go @@ -0,0 +1,288 @@ +package v1 + +import ( + "errors" + "fmt" + "math" + "net/url" + "regexp" + "strings" + "time" +) + +var ( + canonicalUnitPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) + currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`) + idPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,127}$`) +) + +func (d CostDriver) Validate() error { + if d.SchemaVersion != SchemaVersion { + return fmt.Errorf("schema_version must be %q", SchemaVersion) + } + if err := validateID(d.ID); err != nil { + return fmt.Errorf("id: %w", err) + } + if strings.TrimSpace(d.Name) == "" { + return errors.New("name is required") + } + if !validDriverKind(d.Kind) { + return fmt.Errorf("unsupported driver kind %q", d.Kind) + } + if err := d.Quantity.Validate(); err != nil { + return fmt.Errorf("quantity: %w", err) + } + if d.Per != nil { + if err := d.Per.Validate(); err != nil { + return fmt.Errorf("per: %w", err) + } + } + if err := d.UnitPrice.Validate(); err != nil { + return fmt.Errorf("unit_price: %w", err) + } + if d.UnitPrice.Per.Unit != d.Quantity.Unit { + return fmt.Errorf("unit_price.per unit %q must match quantity unit %q", d.UnitPrice.Per.Unit, d.Quantity.Unit) + } + if err := d.Window.Validate(); err != nil { + return fmt.Errorf("window: %w", err) + } + if err := d.Dimensions.Validate(); err != nil { + return fmt.Errorf("dimensions: %w", err) + } + if err := d.Evidence.Validate(); err != nil { + return fmt.Errorf("evidence: %w", err) + } + return nil +} + +func (o CostObservation) Validate() error { + if o.SchemaVersion != SchemaVersion { + return fmt.Errorf("schema_version must be %q", SchemaVersion) + } + if err := validateID(o.ID); err != nil { + return fmt.Errorf("id: %w", err) + } + if len(o.DriverIDs) == 0 { + return errors.New("driver_ids must contain at least one driver") + } + seenDriverIDs := make(map[string]struct{}, len(o.DriverIDs)) + for i, driverID := range o.DriverIDs { + if err := validateID(driverID); err != nil { + return fmt.Errorf("driver_ids[%d]: %w", i, err) + } + if _, exists := seenDriverIDs[driverID]; exists { + return fmt.Errorf("driver_ids[%d]: duplicate driver id %q", i, driverID) + } + seenDriverIDs[driverID] = struct{}{} + } + if err := o.Window.Validate(); err != nil { + return fmt.Errorf("window: %w", err) + } + if err := o.Quantity.Validate(); err != nil { + return fmt.Errorf("quantity: %w", err) + } + if err := o.UnitPrice.Validate(); err != nil { + return fmt.Errorf("unit_price: %w", err) + } + if o.UnitPrice.Per.Unit != o.Quantity.Unit { + return fmt.Errorf("unit_price.per unit %q must match quantity unit %q", o.UnitPrice.Per.Unit, o.Quantity.Unit) + } + if err := o.TotalCost.Validate(); err != nil { + return fmt.Errorf("total_cost: %w", err) + } + if o.UnitPrice.Amount.Currency != o.TotalCost.Currency { + return errors.New("unit_price and total_cost currencies must match") + } + if err := o.Dimensions.Validate(); err != nil { + return fmt.Errorf("dimensions: %w", err) + } + claims := []struct { + name string + evidence Evidence + }{ + {"quantity", o.Evidence.Quantity}, + {"unit_price", o.Evidence.UnitPrice}, + {"total_cost", o.Evidence.TotalCost}, + } + for _, claim := range claims { + if err := claim.evidence.Validate(); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } + } + return nil +} + +func (q Quantity) Validate() error { + if math.IsNaN(q.Value) || math.IsInf(q.Value, 0) || q.Value < 0 { + return errors.New("value must be a finite number greater than or equal to zero") + } + return validateUnit(q.Unit) +} + +func (m Money) Validate() error { + if math.IsNaN(m.Amount) || math.IsInf(m.Amount, 0) || m.Amount < 0 { + return errors.New("amount must be a finite number greater than or equal to zero") + } + if !currencyPattern.MatchString(m.Currency) { + return errors.New("currency must be an uppercase ISO 4217 code") + } + return nil +} + +func (p UnitPrice) Validate() error { + if err := p.Amount.Validate(); err != nil { + return fmt.Errorf("amount: %w", err) + } + if err := p.Per.Validate(); err != nil { + return fmt.Errorf("per: %w", err) + } + if p.Per.Value <= 0 { + return errors.New("per.value must be greater than zero") + } + return nil +} + +func (w TimeWindow) Validate() error { + start, err := time.Parse(time.RFC3339, w.Start) + if err != nil { + return errors.New("start must be an RFC3339 timestamp") + } + end, err := time.Parse(time.RFC3339, w.End) + if err != nil { + return errors.New("end must be an RFC3339 timestamp") + } + if !start.Before(end) { + return errors.New("start must be before end") + } + return nil +} + +func (d Dimensions) Validate() error { + if strings.TrimSpace(d.Workload) == "" { + return errors.New("workload is required") + } + return nil +} + +func (e Evidence) Validate() error { + if !validEvidenceKind(e.Kind) { + return fmt.Errorf("unsupported evidence kind %q", e.Kind) + } + if !validMeasurementKind(e.Measurement) { + return fmt.Errorf("unsupported measurement kind %q", e.Measurement) + } + if !validSourceType(e.Source.Type) { + return fmt.Errorf("unsupported source type %q", e.Source.Type) + } + if !validConfidence(e.Confidence) { + return fmt.Errorf("unsupported confidence %q", e.Confidence) + } + if strings.TrimSpace(e.ConfidenceRationale) == "" { + return errors.New("confidence_rationale is required") + } + if strings.TrimSpace(e.Source.ArtifactIdentity) == "" && strings.TrimSpace(e.Source.URL) == "" { + return errors.New("source requires artifact_identity or url") + } + if e.Source.URL != "" { + parsed, err := url.ParseRequestURI(e.Source.URL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return errors.New("source.url must be an absolute URI") + } + } + if !validCaptureTime(e.Source.CapturedAt) { + return errors.New("source.captured_at must be an RFC3339 timestamp or ISO date") + } + if e.Measurement == MeasurementMeasured && e.Source.Type != SourceTelemetry && + e.Source.Type != SourceRuntimeLedger && e.Source.Type != SourceInvoice { + return errors.New("measured evidence requires telemetry, runtime_ledger, or invoice source") + } + if e.Measurement == MeasurementSynthetic && e.Source.Type != SourceSyntheticFixture { + return errors.New("synthetic measurement requires synthetic_fixture source") + } + if e.Kind == EvidenceBilled && e.Source.Type != SourceInvoice { + return errors.New("billed evidence requires invoice source") + } + if e.Kind == EvidenceUserSupplied && e.Source.Type != SourceUserSupplied { + return errors.New("user_supplied evidence requires user_supplied source") + } + if e.Kind == EvidenceObserved && e.Measurement != MeasurementMeasured && e.Measurement != MeasurementSynthetic { + return errors.New("observed evidence must be measured or synthetic") + } + if e.Kind == EvidencePredicted && e.Measurement == MeasurementMeasured { + return errors.New("predicted evidence cannot claim measured status") + } + return nil +} + +func validateUnit(unit string) error { + if !canonicalUnitPattern.MatchString(unit) { + return errors.New("unit must be a non-empty canonical lowercase identifier") + } + switch unit { + case "unit", "units", "gb", "mb", "kb": + return fmt.Errorf("ambiguous unit %q is not allowed", unit) + } + return nil +} + +func validateID(id string) error { + if !idPattern.MatchString(id) { + return errors.New("must be a non-empty canonical identifier") + } + return nil +} + +func validCaptureTime(value string) bool { + if _, err := time.Parse(time.RFC3339, value); err == nil { + return true + } + _, err := time.Parse(time.DateOnly, value) + return err == nil +} + +func validDriverKind(value DriverKind) bool { + switch value { + case DriverFixed, DriverVariable, DriverCadence, DriverConcurrency, DriverUptime: + return true + default: + return false + } +} + +func validEvidenceKind(value EvidenceKind) bool { + switch value { + case EvidencePredicted, EvidenceObserved, EvidenceBilled, EvidenceUserSupplied: + return true + default: + return false + } +} + +func validMeasurementKind(value MeasurementKind) bool { + switch value { + case MeasurementMeasured, MeasurementSynthetic, MeasurementDerived, MeasurementDeclared: + return true + default: + return false + } +} + +func validSourceType(value SourceType) bool { + switch value { + case SourceTemplate, SourceUserSupplied, SourceRepoDetected, SourceTelemetry, + SourceRuntimeLedger, SourceInvoice, SourceProviderCatalog, + SourceSyntheticFixture, SourceLegacyScenario: + return true + default: + return false + } +} + +func validConfidence(value Confidence) bool { + switch value { + case ConfidenceLow, ConfidenceMedium, ConfidenceHigh: + return true + default: + return false + } +} diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go new file mode 100644 index 0000000..5f57506 --- /dev/null +++ b/pkg/contracts/cost/v1/validate_test.go @@ -0,0 +1,150 @@ +package v1_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + v1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + "github.com/stretchr/testify/require" +) + +func TestAllDriverKindsValidate(t *testing.T) { + for _, kind := range []v1.DriverKind{ + v1.DriverFixed, + v1.DriverVariable, + v1.DriverCadence, + v1.DriverConcurrency, + v1.DriverUptime, + } { + t.Run(string(kind), func(t *testing.T) { + driver := validDriver() + driver.Kind = kind + require.NoError(t, driver.Validate()) + }) + } +} + +func TestDriverRejectsMissingMalformedAndAmbiguousUnits(t *testing.T) { + for _, unit := range []string{"", "commands / month", "GB", "gb", "units"} { + t.Run(unit, func(t *testing.T) { + driver := validDriver() + driver.Quantity.Unit = unit + driver.UnitPrice.Per.Unit = unit + require.Error(t, driver.Validate()) + }) + } +} + +func TestDriverRejectsMissingTimeWindow(t *testing.T) { + driver := validDriver() + driver.Window = v1.TimeWindow{} + require.ErrorContains(t, driver.Validate(), "window") +} + +func TestEvidenceDoesNotTreatProvenanceAsMeasurement(t *testing.T) { + evidence := validEvidence() + evidence.Measurement = v1.MeasurementMeasured + evidence.Source.Type = v1.SourceProviderCatalog + require.ErrorContains(t, evidence.Validate(), "measured evidence requires") +} + +func TestBilledEvidenceRequiresInvoice(t *testing.T) { + evidence := validEvidence() + evidence.Kind = v1.EvidenceBilled + require.ErrorContains(t, evidence.Validate(), "invoice") +} + +func TestObservationRejectsDuplicateDriverIDs(t *testing.T) { + var fixture struct { + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + fixture.Observation.DriverIDs = append(fixture.Observation.DriverIDs, fixture.Observation.DriverIDs[0]) + require.ErrorContains(t, fixture.Observation.Validate(), "duplicate driver id") +} + +func TestUpstashForecastToObservationFixture(t *testing.T) { + var fixture struct { + Drivers []v1.CostDriver `json:"drivers"` + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + + require.Len(t, fixture.Drivers, 1) + require.NoError(t, fixture.Drivers[0].Validate()) + require.NoError(t, fixture.Observation.Validate()) + require.Equal(t, fixture.Drivers[0].ID, fixture.Observation.DriverIDs[0]) + require.Equal(t, v1.MeasurementSynthetic, fixture.Observation.Evidence.Quantity.Measurement) + require.Equal(t, v1.SourceUserSupplied, fixture.Observation.Evidence.UnitPrice.Source.Type) +} + +func TestSchemasContainValidJSON(t *testing.T) { + root := repoRoot(t) + schemaDir := filepath.Join(root, "schemas", "cost-contract", "v1") + entries, err := os.ReadDir(schemaDir) + require.NoError(t, err) + require.Len(t, entries, 3) + + ids := map[string]bool{} + for _, entry := range entries { + data, err := os.ReadFile(filepath.Join(schemaDir, entry.Name())) + require.NoError(t, err) + var schema map[string]any + require.NoError(t, json.Unmarshal(data, &schema), entry.Name()) + id, ok := schema["$id"].(string) + require.True(t, ok, entry.Name()) + require.False(t, ids[id], entry.Name()) + ids[id] = true + } +} + +func validDriver() v1.CostDriver { + return v1.CostDriver{ + SchemaVersion: v1.SchemaVersion, + ID: "driver-1", + Name: "Requests", + Kind: v1.DriverVariable, + Quantity: v1.Quantity{Value: 10, Unit: "request"}, + Per: &v1.Quantity{Value: 1, Unit: "user"}, + UnitPrice: v1.UnitPrice{ + Amount: v1.Money{Amount: 0.001, Currency: "USD"}, + Per: v1.Quantity{Value: 1, Unit: "request"}, + }, + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + Dimensions: v1.Dimensions{Workload: "api_requests"}, + Evidence: validEvidence(), + } +} + +func validEvidence() v1.Evidence { + return v1.Evidence{ + Kind: v1.EvidencePredicted, + Measurement: v1.MeasurementDeclared, + Source: v1.SourceReference{ + Type: v1.SourceUserSupplied, + ArtifactIdentity: "test", + CapturedAt: "2026-08-01", + }, + Confidence: v1.ConfidenceMedium, + ConfidenceRationale: "Explicit test assumption.", + } +} + +func decodeFixture(t *testing.T, name string, target any) { + t.Helper() + data, err := os.ReadFile(filepath.Join(repoRoot(t), "test", "fixtures", "cost_contract", "v1", name)) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, target)) +} + +func repoRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) + require.NoError(t, err) + return root +} diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json new file mode 100644 index 0000000..fa1de0b --- /dev/null +++ b/schemas/cost-contract/v1/common.schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/IntelIP/ProfitCtl/schemas/cost-contract/v1/common.schema.json", + "$defs": { + "quantity": { + "type": "object", + "additionalProperties": false, + "required": ["value", "unit"], + "properties": { + "value": { "type": "number", "minimum": 0 }, + "unit": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,62}$", + "not": { "enum": ["unit", "units", "gb", "mb", "kb"] } + } + } + }, + "money": { + "type": "object", + "additionalProperties": false, + "required": ["amount", "currency"], + "properties": { + "amount": { "type": "number", "minimum": 0 }, + "currency": { "type": "string", "pattern": "^[A-Z]{3}$" } + } + }, + "unit_price": { + "type": "object", + "additionalProperties": false, + "required": ["amount", "per"], + "properties": { + "amount": { "$ref": "#/$defs/money" }, + "per": { + "allOf": [ + { "$ref": "#/$defs/quantity" }, + { "properties": { "value": { "exclusiveMinimum": 0 } } } + ] + } + } + }, + "window": { + "type": "object", + "additionalProperties": false, + "required": ["start", "end"], + "properties": { + "start": { "type": "string", "format": "date-time" }, + "end": { "type": "string", "format": "date-time" } + } + }, + "dimensions": { + "type": "object", + "additionalProperties": false, + "required": ["workload"], + "properties": { + "provider": { "type": "string" }, + "service": { "type": "string" }, + "region": { "type": "string" }, + "tier": { "type": "string" }, + "workload": { "type": "string", "minLength": 1 } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["type", "captured_at"], + "anyOf": [ + { "required": ["artifact_identity"] }, + { "required": ["url"] } + ], + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "repo_detected", + "telemetry", + "runtime_ledger", + "invoice", + "provider_catalog", + "synthetic_fixture", + "legacy_scenario" + ] + }, + "artifact_identity": { "type": "string", "minLength": 1 }, + "url": { "type": "string", "format": "uri" }, + "captured_at": { + "type": "string", + "description": "ISO date or RFC3339 timestamp." + } + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "measurement", + "source", + "confidence", + "confidence_rationale" + ], + "properties": { + "kind": { + "enum": ["predicted", "observed", "billed", "user_supplied"] + }, + "measurement": { + "enum": ["measured", "synthetic", "derived", "declared"] + }, + "source": { "$ref": "#/$defs/source" }, + "confidence": { "enum": ["low", "medium", "high"] }, + "confidence_rationale": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/schemas/cost-contract/v1/cost-driver.schema.json b/schemas/cost-contract/v1/cost-driver.schema.json new file mode 100644 index 0000000..46b92d8 --- /dev/null +++ b/schemas/cost-contract/v1/cost-driver.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/IntelIP/ProfitCtl/schemas/cost-contract/v1/cost-driver.schema.json", + "title": "ProfitCtl Cost Driver v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "id", + "name", + "kind", + "quantity", + "unit_price", + "window", + "dimensions", + "evidence" + ], + "properties": { + "schema_version": { "const": "profitctl.cost/v1" }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{0,127}$" + }, + "name": { "type": "string", "minLength": 1 }, + "kind": { + "enum": ["fixed", "variable", "cadence", "concurrency", "uptime"] + }, + "quantity": { + "$ref": "common.schema.json#/$defs/quantity" + }, + "per": { + "$ref": "common.schema.json#/$defs/quantity" + }, + "unit_price": { + "$ref": "common.schema.json#/$defs/unit_price" + }, + "window": { + "$ref": "common.schema.json#/$defs/window" + }, + "dimensions": { + "$ref": "common.schema.json#/$defs/dimensions" + }, + "evidence": { + "$ref": "common.schema.json#/$defs/evidence" + } + } +} diff --git a/schemas/cost-contract/v1/cost-observation.schema.json b/schemas/cost-contract/v1/cost-observation.schema.json new file mode 100644 index 0000000..20378c9 --- /dev/null +++ b/schemas/cost-contract/v1/cost-observation.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/IntelIP/ProfitCtl/schemas/cost-contract/v1/cost-observation.schema.json", + "title": "ProfitCtl Cost Observation v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "id", + "driver_ids", + "window", + "quantity", + "unit_price", + "total_cost", + "dimensions", + "evidence" + ], + "properties": { + "schema_version": { "const": "profitctl.cost/v1" }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{0,127}$" + }, + "driver_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9._-]{0,127}$" + } + }, + "window": { + "$ref": "common.schema.json#/$defs/window" + }, + "quantity": { + "$ref": "common.schema.json#/$defs/quantity" + }, + "unit_price": { + "$ref": "common.schema.json#/$defs/unit_price" + }, + "total_cost": { + "$ref": "common.schema.json#/$defs/money" + }, + "dimensions": { + "$ref": "common.schema.json#/$defs/dimensions" + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["quantity", "unit_price", "total_cost"], + "properties": { + "quantity": { + "$ref": "common.schema.json#/$defs/evidence" + }, + "unit_price": { + "$ref": "common.schema.json#/$defs/evidence" + }, + "total_cost": { + "$ref": "common.schema.json#/$defs/evidence" + } + } + } + } +} diff --git a/test/fixtures/cost_contract/v1/legacy_valid_config_mapping.json b/test/fixtures/cost_contract/v1/legacy_valid_config_mapping.json new file mode 100644 index 0000000..8a39b29 --- /dev/null +++ b/test/fixtures/cost_contract/v1/legacy_valid_config_mapping.json @@ -0,0 +1,26 @@ +{ + "source_scenario": "test/fixtures/valid_config.yml", + "mapping": { + "artifact_identity": "test/fixtures/valid_config.yml", + "captured_at": "2026-08-01", + "currency": "USD", + "window": { + "start": "2026-08-01T00:00:00Z", + "end": "2026-09-01T00:00:00Z" + }, + "variable_units": { + "API Calls": "request", + "Storage (S3)": "gibibyte", + "LLM Tokens": "token" + } + }, + "expected": [ + { "name": "AWS EC2", "kind": "fixed", "unit": "commitment", "per_unit": "month" }, + { "name": "Database (RDS)", "kind": "fixed", "unit": "commitment", "per_unit": "month" }, + { "name": "Auth Service (Auth0)", "kind": "fixed", "unit": "commitment", "per_unit": "month" }, + { "name": "Dev Team Tools", "kind": "fixed", "unit": "commitment", "per_unit": "month" }, + { "name": "API Calls", "kind": "variable", "unit": "request", "per_unit": "user" }, + { "name": "Storage (S3)", "kind": "variable", "unit": "gibibyte", "per_unit": "user" }, + { "name": "LLM Tokens", "kind": "variable", "unit": "token", "per_unit": "user" } + ] +} diff --git a/test/fixtures/cost_contract/v1/upstash_idle_polling.json b/test/fixtures/cost_contract/v1/upstash_idle_polling.json new file mode 100644 index 0000000..62d562b --- /dev/null +++ b/test/fixtures/cost_contract/v1/upstash_idle_polling.json @@ -0,0 +1,121 @@ +{ + "fixture_status": "synthetic_historical_shape", + "note": "Deterministic local fixture. Quantities mirror the accepted idle-polling case; rate is explicitly user-supplied and is not a current Upstash price.", + "drivers": [ + { + "schema_version": "profitctl.cost/v1", + "id": "upstash-idle-poll-cadence", + "name": "Idle worker polling commands", + "kind": "cadence", + "quantity": { + "value": 1386000, + "unit": "command" + }, + "per": { + "value": 1, + "unit": "month" + }, + "unit_price": { + "amount": { + "amount": 0.000001, + "currency": "USD" + }, + "per": { + "value": 1, + "unit": "command" + } + }, + "window": { + "start": "2026-07-01T00:00:00Z", + "end": "2026-08-01T00:00:00Z" + }, + "dimensions": { + "provider": "upstash", + "service": "redis", + "tier": "synthetic", + "workload": "idle_worker_polling" + }, + "evidence": { + "kind": "predicted", + "measurement": "declared", + "source": { + "type": "user_supplied", + "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json", + "captured_at": "2026-08-01" + }, + "confidence": "low", + "confidence_rationale": "Historical quantity and synthetic unit rate; no live provider or billing claim." + } + } + ], + "observation": { + "schema_version": "profitctl.cost/v1", + "id": "upstash-idle-polling-synthetic-observation", + "driver_ids": [ + "upstash-idle-poll-cadence" + ], + "window": { + "start": "2026-07-01T00:00:00Z", + "end": "2026-08-01T00:00:00Z" + }, + "quantity": { + "value": 1386000, + "unit": "command" + }, + "unit_price": { + "amount": { + "amount": 0.000001, + "currency": "USD" + }, + "per": { + "value": 1, + "unit": "command" + } + }, + "total_cost": { + "amount": 1.386, + "currency": "USD" + }, + "dimensions": { + "provider": "upstash", + "service": "redis", + "tier": "synthetic", + "workload": "idle_worker_polling" + }, + "evidence": { + "quantity": { + "kind": "observed", + "measurement": "synthetic", + "source": { + "type": "synthetic_fixture", + "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json#observation.quantity", + "captured_at": "2026-08-01" + }, + "confidence": "low", + "confidence_rationale": "Synthetic fixture preserves historical shape but does not prove runtime measurement." + }, + "unit_price": { + "kind": "user_supplied", + "measurement": "declared", + "source": { + "type": "user_supplied", + "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json#observation.unit_price", + "captured_at": "2026-08-01" + }, + "confidence": "low", + "confidence_rationale": "Synthetic rate for contract validation; not current provider pricing." + }, + "total_cost": { + "kind": "predicted", + "measurement": "derived", + "source": { + "type": "synthetic_fixture", + "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json#observation.total_cost", + "captured_at": "2026-08-01" + }, + "confidence": "low", + "confidence_rationale": "Derived deterministically from synthetic quantity and user-supplied rate." + } + } + } +} From 143fbd0fa61663f5104751d94d74ba75e3751a7c Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 09:57:49 -0700 Subject: [PATCH 02/12] PCTL-18: tighten contract authority and compatibility --- docs/contracts/cost-contract-v1.md | 33 ++++++++++- pkg/contracts/cost/v1/legacy.go | 20 ++++--- pkg/contracts/cost/v1/legacy_test.go | 41 +++++++++++++ pkg/contracts/cost/v1/types.go | 4 ++ pkg/contracts/cost/v1/validate.go | 58 ++++++++++++++++++- pkg/contracts/cost/v1/validate_test.go | 58 +++++++++++++++++++ schemas/cost-contract/v1/common.schema.json | 44 ++++++++++++++ .../cost-contract/v1/cost-driver.schema.json | 15 ++++- 8 files changed, 262 insertions(+), 11 deletions(-) diff --git a/docs/contracts/cost-contract-v1.md b/docs/contracts/cost-contract-v1.md index fc50377..31d0959 100644 --- a/docs/contracts/cost-contract-v1.md +++ b/docs/contracts/cost-contract-v1.md @@ -37,6 +37,10 @@ Every driver requires: - evidence class, measurement status, source identity, capture time, confidence, and rationale. +`variable` and `cadence` drivers also require a `per` scale basis. A variable +driver cannot omit whether it scales per `user`, `paid_user`, or another +canonical subject. A cadence driver cannot omit its time denominator. + Units use lowercase canonical identifiers such as `request`, `command`, `token`, `gibibyte`, `user`, `worker`, `hour`, or `month`. Ambiguous storage units such as `GB`/`gb`, free-text ratios, and generic `unit`/`units` fail @@ -57,6 +61,19 @@ Claim-level evidence matters. Telemetry may own quantity while a provider catalog owns a planning rate and an invoice owns billed total. One source must not silently stand in for all three claims. +Claim authority is validated: + +- quantity: template, user input, repo detection, telemetry, runtime ledger, + synthetic fixture, or legacy scenario; +- unit price: template, user input, repo detection, invoice, provider catalog, + synthetic fixture, or legacy scenario; +- total cost: template, user input, invoice, ProfitCtl-derived artifact, + synthetic fixture, or legacy scenario. + +Telemetry and runtime-ledger sources cannot silently become price or total-cost +evidence. Provider catalogs cannot silently become usage or billed-total +evidence. + ## Evidence Semantics Evidence kinds: @@ -79,9 +96,14 @@ Rules: - `billed` requires invoice source; - `user_supplied` requires user-supplied source; - synthetic observations stay visibly synthetic; +- derived totals must equal quantity multiplied by unit price and divided by + price-basis quantity within floating-point tolerance; - missing source identity, capture time, confidence rationale, units, time windows, or currency fails closed. +Provider-catalog evidence additionally requires `refresh_owner`, +`refresh_cadence`, and `stale_after`, and cannot claim high confidence. + ## Current Scenario Compatibility Legacy scenarios remain authoritative for current simulation behavior. @@ -93,13 +115,22 @@ Mapping rules: | Legacy field | v1 mapping | | --- | --- | | `fixed_costs[].amount` | unit price per `commitment` | -| `fixed_costs[].period` | driver `per` unit: `day`, `month`, or `year` | +| `fixed_costs[].period` | explicit legacy-normalized commitment count and time basis | | `variable_costs[].cost_per_unit` | unit-price amount | | `variable_costs[].units_per_user` | quantity per one `user` | | absent variable unit | caller must supply explicit name-to-unit mapping | | absent scenario currency/window | caller must supply both | | absent source | low-confidence `legacy_scenario`; never measured | +Legacy normalization details: + +- daily fixed costs become 30 commitments per month because the current engine + uses a fixed 30-day simulated month; +- monthly fixed costs become one commitment per month; +- yearly fixed costs become one commitment per year; +- `user_scope: paid_users` maps to `paid_user`; other legacy variable costs map + to `user`. + The compatibility fixture `test/fixtures/cost_contract/v1/legacy_valid_config_mapping.json` maps the current `valid_config.yml` scenario. Tests prove mapped drivers validate and diff --git a/pkg/contracts/cost/v1/legacy.go b/pkg/contracts/cost/v1/legacy.go index f4b1e26..6309b70 100644 --- a/pkg/contracts/cost/v1/legacy.go +++ b/pkg/contracts/cost/v1/legacy.go @@ -35,7 +35,7 @@ func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapp drivers := make([]CostDriver, 0, len(fixed)+len(variable)) for i, cost := range fixed { - periodUnit, err := legacyPeriodUnit(cost.Period) + quantity, periodUnit, err := legacyFixedBasis(cost.Period) if err != nil { return nil, fmt.Errorf("fixed cost %q: %w", cost.Name, err) } @@ -44,7 +44,7 @@ func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapp ID: fmt.Sprintf("legacy-fixed-%d", i+1), Name: cost.Name, Kind: DriverFixed, - Quantity: Quantity{Value: 1, Unit: "commitment"}, + Quantity: Quantity{Value: quantity, Unit: "commitment"}, Per: &Quantity{Value: 1, Unit: periodUnit}, UnitPrice: UnitPrice{ Amount: Money{Amount: cost.Amount, Currency: mapping.Currency}, @@ -65,13 +65,17 @@ func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapp if err := validateUnit(unit); err != nil { return nil, fmt.Errorf("variable cost %q unit: %w", cost.Name, err) } + subjectUnit := "user" + if types.NormalizeVariableCostUserScope(cost.UserScope) == types.UserScopePaidUsers { + subjectUnit = "paid_user" + } driver := CostDriver{ SchemaVersion: SchemaVersion, ID: fmt.Sprintf("legacy-variable-%d", i+1), Name: cost.Name, Kind: DriverVariable, Quantity: Quantity{Value: cost.UnitsPerUser, Unit: unit}, - Per: &Quantity{Value: 1, Unit: "user"}, + Per: &Quantity{Value: 1, Unit: subjectUnit}, UnitPrice: UnitPrice{ Amount: Money{Amount: cost.CostPerUnit, Currency: mapping.Currency}, Per: Quantity{Value: 1, Unit: unit}, @@ -117,15 +121,15 @@ func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { return evidence } -func legacyPeriodUnit(period types.CostPeriod) (string, error) { +func legacyFixedBasis(period types.CostPeriod) (float64, string, error) { switch period { case types.PeriodDaily: - return "day", nil + return 30, "month", nil case types.PeriodMonthly: - return "month", nil + return 1, "month", nil case types.PeriodYearly: - return "year", nil + return 1, "year", nil default: - return "", fmt.Errorf("unsupported period %q", period) + return 0, "", fmt.Errorf("unsupported period %q", period) } } diff --git a/pkg/contracts/cost/v1/legacy_test.go b/pkg/contracts/cost/v1/legacy_test.go index ab91a55..776d15a 100644 --- a/pkg/contracts/cost/v1/legacy_test.go +++ b/pkg/contracts/cost/v1/legacy_test.go @@ -9,6 +9,7 @@ import ( "github.com/IntelIP/ProfitCtl/internal/config" "github.com/IntelIP/ProfitCtl/internal/cost" v1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" + "github.com/IntelIP/ProfitCtl/pkg/types" "github.com/stretchr/testify/require" ) @@ -69,6 +70,46 @@ func TestLegacyMappingFailsClosedWithoutVariableUnit(t *testing.T) { require.ErrorContains(t, err, "unit") } +func TestLegacyMappingPreservesPaidUserScope(t *testing.T) { + drivers, err := v1.MapLegacyCosts(nil, []types.VariableCost{{ + Name: "Payment processing", + CostPerUnit: 0.30, + UnitsPerUser: 1, + UserScope: types.UserScopePaidUsers, + }}, v1.LegacyMapping{ + ArtifactIdentity: "paid.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + VariableUnits: map[string]string{"Payment processing": "payment"}, + }) + require.NoError(t, err) + require.Equal(t, "paid_user", drivers[0].Per.Unit) +} + +func TestLegacyMappingPreservesThirtyDayDailyNormalization(t *testing.T) { + drivers, err := v1.MapLegacyCosts([]types.FixedCost{{ + Name: "Daily worker", + Amount: 2, + Period: types.PeriodDaily, + }}, nil, v1.LegacyMapping{ + ArtifactIdentity: "daily.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-07-01T00:00:00Z", + End: "2026-08-01T00:00:00Z", + }, + }) + require.NoError(t, err) + require.Equal(t, float64(30), drivers[0].Quantity.Value) + require.Equal(t, "month", drivers[0].Per.Unit) + require.Equal(t, float64(60), drivers[0].Quantity.Value*drivers[0].UnitPrice.Amount.Amount) +} + func TestCompatibilityFixtureIsStableJSON(t *testing.T) { path := filepath.Join(repoRoot(t), "test", "fixtures", "cost_contract", "v1", "legacy_valid_config_mapping.json") data, err := os.ReadFile(path) diff --git a/pkg/contracts/cost/v1/types.go b/pkg/contracts/cost/v1/types.go index 4151607..81dcdd6 100644 --- a/pkg/contracts/cost/v1/types.go +++ b/pkg/contracts/cost/v1/types.go @@ -42,6 +42,7 @@ const ( SourceRuntimeLedger SourceType = "runtime_ledger" SourceInvoice SourceType = "invoice" SourceProviderCatalog SourceType = "provider_catalog" + SourceProfitCtlDerived SourceType = "profitctl_derived" SourceSyntheticFixture SourceType = "synthetic_fixture" SourceLegacyScenario SourceType = "legacy_scenario" ) @@ -87,6 +88,9 @@ type SourceReference struct { ArtifactIdentity string `json:"artifact_identity,omitempty" yaml:"artifact_identity,omitempty"` URL string `json:"url,omitempty" yaml:"url,omitempty"` CapturedAt string `json:"captured_at" yaml:"captured_at"` + RefreshOwner string `json:"refresh_owner,omitempty" yaml:"refresh_owner,omitempty"` + RefreshCadence string `json:"refresh_cadence,omitempty" yaml:"refresh_cadence,omitempty"` + StaleAfter string `json:"stale_after,omitempty" yaml:"stale_after,omitempty"` } // Evidence classifies a claim separately from its provenance. A source label diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go index d7e4f15..d48fb94 100644 --- a/pkg/contracts/cost/v1/validate.go +++ b/pkg/contracts/cost/v1/validate.go @@ -29,6 +29,9 @@ func (d CostDriver) Validate() error { if !validDriverKind(d.Kind) { return fmt.Errorf("unsupported driver kind %q", d.Kind) } + if (d.Kind == DriverVariable || d.Kind == DriverCadence) && d.Per == nil { + return fmt.Errorf("%s driver requires per scale basis", d.Kind) + } if err := d.Quantity.Validate(); err != nil { return fmt.Errorf("quantity: %w", err) } @@ -108,6 +111,16 @@ func (o CostObservation) Validate() error { if err := claim.evidence.Validate(); err != nil { return fmt.Errorf("evidence.%s: %w", claim.name, err) } + if err := validateClaimAuthority(claim.name, claim.evidence.Source.Type); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } + } + if o.Evidence.TotalCost.Measurement == MeasurementDerived { + expected := o.Quantity.Value * o.UnitPrice.Amount.Amount / o.UnitPrice.Per.Value + tolerance := math.Max(1e-9, math.Abs(expected)*1e-9) + if math.Abs(o.TotalCost.Amount-expected) > tolerance { + return fmt.Errorf("derived total_cost amount %.12g must equal quantity times unit price %.12g", o.TotalCost.Amount, expected) + } } return nil } @@ -192,6 +205,19 @@ func (e Evidence) Validate() error { if !validCaptureTime(e.Source.CapturedAt) { return errors.New("source.captured_at must be an RFC3339 timestamp or ISO date") } + if e.Source.Type == SourceProviderCatalog { + if strings.TrimSpace(e.Source.RefreshOwner) == "" || + strings.TrimSpace(e.Source.RefreshCadence) == "" || + strings.TrimSpace(e.Source.StaleAfter) == "" { + return errors.New("provider_catalog source requires refresh_owner, refresh_cadence, and stale_after") + } + if !validCaptureTime(e.Source.StaleAfter) { + return errors.New("source.stale_after must be an RFC3339 timestamp or ISO date") + } + if e.Confidence == ConfidenceHigh { + return errors.New("provider_catalog evidence cannot claim high confidence") + } + } if e.Measurement == MeasurementMeasured && e.Source.Type != SourceTelemetry && e.Source.Type != SourceRuntimeLedger && e.Source.Type != SourceInvoice { return errors.New("measured evidence requires telemetry, runtime_ledger, or invoice source") @@ -214,6 +240,36 @@ func (e Evidence) Validate() error { return nil } +func validateClaimAuthority(claim string, source SourceType) error { + var allowed bool + switch claim { + case "quantity": + switch source { + case SourceTemplate, SourceUserSupplied, SourceRepoDetected, SourceTelemetry, + SourceRuntimeLedger, SourceSyntheticFixture, SourceLegacyScenario: + allowed = true + } + case "unit_price": + switch source { + case SourceTemplate, SourceUserSupplied, SourceRepoDetected, SourceInvoice, + SourceProviderCatalog, SourceSyntheticFixture, SourceLegacyScenario: + allowed = true + } + case "total_cost": + switch source { + case SourceTemplate, SourceUserSupplied, SourceInvoice, + SourceProfitCtlDerived, SourceSyntheticFixture, SourceLegacyScenario: + allowed = true + } + default: + return fmt.Errorf("unsupported claim %q", claim) + } + if !allowed { + return fmt.Errorf("source type %q is not authoritative for %s claim", source, claim) + } + return nil +} + func validateUnit(unit string) error { if !canonicalUnitPattern.MatchString(unit) { return errors.New("unit must be a non-empty canonical lowercase identifier") @@ -271,7 +327,7 @@ func validSourceType(value SourceType) bool { switch value { case SourceTemplate, SourceUserSupplied, SourceRepoDetected, SourceTelemetry, SourceRuntimeLedger, SourceInvoice, SourceProviderCatalog, - SourceSyntheticFixture, SourceLegacyScenario: + SourceProfitCtlDerived, SourceSyntheticFixture, SourceLegacyScenario: return true default: return false diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go index 5f57506..ba99eea 100644 --- a/pkg/contracts/cost/v1/validate_test.go +++ b/pkg/contracts/cost/v1/validate_test.go @@ -43,10 +43,22 @@ func TestDriverRejectsMissingTimeWindow(t *testing.T) { require.ErrorContains(t, driver.Validate(), "window") } +func TestScalingDriversRequirePerBasis(t *testing.T) { + for _, kind := range []v1.DriverKind{v1.DriverVariable, v1.DriverCadence} { + driver := validDriver() + driver.Kind = kind + driver.Per = nil + require.ErrorContains(t, driver.Validate(), "requires per scale basis") + } +} + func TestEvidenceDoesNotTreatProvenanceAsMeasurement(t *testing.T) { evidence := validEvidence() evidence.Measurement = v1.MeasurementMeasured evidence.Source.Type = v1.SourceProviderCatalog + evidence.Source.RefreshOwner = "catalog-maintainer" + evidence.Source.RefreshCadence = "30d" + evidence.Source.StaleAfter = "2026-09-01" require.ErrorContains(t, evidence.Validate(), "measured evidence requires") } @@ -56,6 +68,52 @@ func TestBilledEvidenceRequiresInvoice(t *testing.T) { require.ErrorContains(t, evidence.Validate(), "invoice") } +func TestObservationEnforcesClaimSpecificAuthority(t *testing.T) { + var fixture struct { + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + fixture.Observation.Evidence.UnitPrice = v1.Evidence{ + Kind: v1.EvidenceObserved, + Measurement: v1.MeasurementMeasured, + Source: v1.SourceReference{ + Type: v1.SourceTelemetry, + ArtifactIdentity: "telemetry", + CapturedAt: "2026-08-01", + }, + Confidence: v1.ConfidenceHigh, + ConfidenceRationale: "Measured usage only.", + } + require.ErrorContains(t, fixture.Observation.Validate(), "not authoritative for unit_price") +} + +func TestObservationValidatesDerivedTotalArithmetic(t *testing.T) { + var fixture struct { + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + fixture.Observation.Evidence.TotalCost.Source.Type = v1.SourceProfitCtlDerived + fixture.Observation.Evidence.TotalCost.Source.ArtifactIdentity = "profitctl://derived/upstash-total" + require.NoError(t, fixture.Observation.Validate()) + fixture.Observation.TotalCost.Amount = 999 + require.ErrorContains(t, fixture.Observation.Validate(), "derived total_cost") +} + +func TestProviderCatalogRequiresStalePolicyAndCapsConfidence(t *testing.T) { + evidence := validEvidence() + evidence.Source.Type = v1.SourceProviderCatalog + require.ErrorContains(t, evidence.Validate(), "refresh_owner") + + evidence.Source.RefreshOwner = "catalog-maintainer" + evidence.Source.RefreshCadence = "30d" + evidence.Source.StaleAfter = "2026-09-01" + evidence.Confidence = v1.ConfidenceHigh + require.ErrorContains(t, evidence.Validate(), "cannot claim high confidence") + + evidence.Confidence = v1.ConfidenceMedium + require.NoError(t, evidence.Validate()) +} + func TestObservationRejectsDuplicateDriverIDs(t *testing.T) { var fixture struct { Observation v1.CostObservation `json:"observation"` diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json index fa1de0b..5620041 100644 --- a/schemas/cost-contract/v1/common.schema.json +++ b/schemas/cost-contract/v1/common.schema.json @@ -63,6 +63,23 @@ "type": "object", "additionalProperties": false, "required": ["type", "captured_at"], + "allOf": [ + { + "if": { + "properties": { + "type": { "const": "provider_catalog" } + }, + "required": ["type"] + }, + "then": { + "required": [ + "refresh_owner", + "refresh_cadence", + "stale_after" + ] + } + } + ], "anyOf": [ { "required": ["artifact_identity"] }, { "required": ["url"] } @@ -77,6 +94,7 @@ "runtime_ledger", "invoice", "provider_catalog", + "profitctl_derived", "synthetic_fixture", "legacy_scenario" ] @@ -86,12 +104,38 @@ "captured_at": { "type": "string", "description": "ISO date or RFC3339 timestamp." + }, + "refresh_owner": { "type": "string", "minLength": 1 }, + "refresh_cadence": { "type": "string", "minLength": 1 }, + "stale_after": { + "type": "string", + "description": "ISO date or RFC3339 timestamp." } } }, "evidence": { "type": "object", "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "provider_catalog" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "confidence": { "enum": ["low", "medium"] } + } + } + } + ], "required": [ "kind", "measurement", diff --git a/schemas/cost-contract/v1/cost-driver.schema.json b/schemas/cost-contract/v1/cost-driver.schema.json index 46b92d8..9b3e7e3 100644 --- a/schemas/cost-contract/v1/cost-driver.schema.json +++ b/schemas/cost-contract/v1/cost-driver.schema.json @@ -43,5 +43,18 @@ "evidence": { "$ref": "common.schema.json#/$defs/evidence" } - } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { "enum": ["variable", "cadence"] } + }, + "required": ["kind"] + }, + "then": { + "required": ["per"] + } + } + ] } From 49ed95ca248f69fc55cb3103c818dec290288c26 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 10:10:16 -0700 Subject: [PATCH 03/12] fix: close cost contract review gaps --- pkg/contracts/cost/v1/legacy.go | 12 +++++++++ pkg/contracts/cost/v1/legacy_test.go | 27 +++++++++++++++++++ pkg/contracts/cost/v1/validate.go | 6 +++++ pkg/contracts/cost/v1/validate_test.go | 10 +++++++ schemas/cost-contract/v1/common.schema.json | 18 +++++++++++++ .../cost-contract/v1/cost-driver.schema.json | 10 ++++++- 6 files changed, 82 insertions(+), 1 deletion(-) diff --git a/pkg/contracts/cost/v1/legacy.go b/pkg/contracts/cost/v1/legacy.go index 6309b70..9f883f0 100644 --- a/pkg/contracts/cost/v1/legacy.go +++ b/pkg/contracts/cost/v1/legacy.go @@ -108,6 +108,18 @@ func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { return evidence } + // Legacy provider_catalog entries predate the required refresh policy. Keep + // them mappable without upgrading incomplete provenance into v1 catalog + // evidence. + if SourceType(source.Type) == SourceProviderCatalog { + evidence.Source.URL = source.URL + if source.CapturedAt != "" { + evidence.Source.CapturedAt = source.CapturedAt + } + evidence.ConfidenceRationale = "Legacy provider catalog assumption mapped without v1 refresh policy or current-price claim." + return evidence + } + evidence.Source.Type = SourceType(source.Type) evidence.Source.URL = source.URL evidence.Source.CapturedAt = source.CapturedAt diff --git a/pkg/contracts/cost/v1/legacy_test.go b/pkg/contracts/cost/v1/legacy_test.go index 776d15a..ee016ec 100644 --- a/pkg/contracts/cost/v1/legacy_test.go +++ b/pkg/contracts/cost/v1/legacy_test.go @@ -110,6 +110,33 @@ func TestLegacyMappingPreservesThirtyDayDailyNormalization(t *testing.T) { require.Equal(t, float64(60), drivers[0].Quantity.Value*drivers[0].UnitPrice.Amount.Amount) } +func TestLegacyMappingDowngradesIncompleteProviderCatalogProvenance(t *testing.T) { + drivers, err := v1.MapLegacyCosts([]types.FixedCost{{ + Name: "Catalog-backed database", + Amount: 20, + Period: types.PeriodMonthly, + Source: &types.CostSource{ + Type: types.CostSourceProviderCatalog, + URL: "https://example.com/pricing", + CapturedAt: "2026-07-31", + Confidence: types.CostSourceConfidenceMedium, + }, + }}, nil, v1.LegacyMapping{ + ArtifactIdentity: "legacy.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + }) + require.NoError(t, err) + require.Equal(t, v1.SourceLegacyScenario, drivers[0].Evidence.Source.Type) + require.Equal(t, "https://example.com/pricing", drivers[0].Evidence.Source.URL) + require.Equal(t, v1.ConfidenceLow, drivers[0].Evidence.Confidence) + require.Contains(t, drivers[0].Evidence.ConfidenceRationale, "without v1 refresh policy") +} + func TestCompatibilityFixtureIsStableJSON(t *testing.T) { path := filepath.Join(repoRoot(t), "test", "fixtures", "cost_contract", "v1", "legacy_valid_config_mapping.json") data, err := os.ReadFile(path) diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go index d48fb94..4e03789 100644 --- a/pkg/contracts/cost/v1/validate.go +++ b/pkg/contracts/cost/v1/validate.go @@ -39,6 +39,9 @@ func (d CostDriver) Validate() error { if err := d.Per.Validate(); err != nil { return fmt.Errorf("per: %w", err) } + if (d.Kind == DriverVariable || d.Kind == DriverCadence) && d.Per.Value <= 0 { + return fmt.Errorf("%s driver per.value must be greater than zero", d.Kind) + } } if err := d.UnitPrice.Validate(); err != nil { return fmt.Errorf("unit_price: %w", err) @@ -228,6 +231,9 @@ func (e Evidence) Validate() error { if e.Kind == EvidenceBilled && e.Source.Type != SourceInvoice { return errors.New("billed evidence requires invoice source") } + if e.Kind == EvidenceBilled && e.Measurement != MeasurementMeasured { + return errors.New("billed evidence must be measured") + } if e.Kind == EvidenceUserSupplied && e.Source.Type != SourceUserSupplied { return errors.New("user_supplied evidence requires user_supplied source") } diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go index ba99eea..dac4fef 100644 --- a/pkg/contracts/cost/v1/validate_test.go +++ b/pkg/contracts/cost/v1/validate_test.go @@ -49,6 +49,9 @@ func TestScalingDriversRequirePerBasis(t *testing.T) { driver.Kind = kind driver.Per = nil require.ErrorContains(t, driver.Validate(), "requires per scale basis") + + driver.Per = &v1.Quantity{Value: 0, Unit: "user"} + require.ErrorContains(t, driver.Validate(), "per.value must be greater than zero") } } @@ -66,6 +69,13 @@ func TestBilledEvidenceRequiresInvoice(t *testing.T) { evidence := validEvidence() evidence.Kind = v1.EvidenceBilled require.ErrorContains(t, evidence.Validate(), "invoice") + + evidence.Source.Type = v1.SourceInvoice + evidence.Measurement = v1.MeasurementDeclared + require.ErrorContains(t, evidence.Validate(), "must be measured") + + evidence.Measurement = v1.MeasurementMeasured + require.NoError(t, evidence.Validate()) } func TestObservationEnforcesClaimSpecificAuthority(t *testing.T) { diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json index 5620041..df5c695 100644 --- a/schemas/cost-contract/v1/common.schema.json +++ b/schemas/cost-contract/v1/common.schema.json @@ -134,6 +134,24 @@ "confidence": { "enum": ["low", "medium"] } } } + }, + { + "if": { + "properties": { + "kind": { "const": "billed" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "measurement": { "const": "measured" }, + "source": { + "properties": { + "type": { "const": "invoice" } + } + } + } + } } ], "required": [ diff --git a/schemas/cost-contract/v1/cost-driver.schema.json b/schemas/cost-contract/v1/cost-driver.schema.json index 9b3e7e3..44e0bc1 100644 --- a/schemas/cost-contract/v1/cost-driver.schema.json +++ b/schemas/cost-contract/v1/cost-driver.schema.json @@ -53,7 +53,15 @@ "required": ["kind"] }, "then": { - "required": ["per"] + "required": ["per"], + "properties": { + "per": { + "allOf": [ + { "$ref": "common.schema.json#/$defs/quantity" }, + { "properties": { "value": { "exclusiveMinimum": 0 } } } + ] + } + } } } ] From 9d6b6d13ce92f074719bb824d7c62afd47117204 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 10:25:06 -0700 Subject: [PATCH 04/12] fix: preserve stochastic cost bases --- docs/contracts/cost-contract-v1.md | 8 ++++ pkg/contracts/cost/v1/legacy.go | 21 +++++++-- pkg/contracts/cost/v1/legacy_test.go | 28 ++++++++++++ pkg/contracts/cost/v1/types.go | 40 ++++++++++++----- pkg/contracts/cost/v1/validate.go | 38 +++++++++++++++- pkg/contracts/cost/v1/validate_test.go | 22 +++++++++ schemas/cost-contract/v1/common.schema.json | 45 +++++++++++++++++++ .../cost-contract/v1/cost-driver.schema.json | 24 ++++++---- 8 files changed, 203 insertions(+), 23 deletions(-) diff --git a/docs/contracts/cost-contract-v1.md b/docs/contracts/cost-contract-v1.md index 31d0959..407ff47 100644 --- a/docs/contracts/cost-contract-v1.md +++ b/docs/contracts/cost-contract-v1.md @@ -40,6 +40,12 @@ Every driver requires: `variable` and `cadence` drivers also require a `per` scale basis. A variable driver cannot omit whether it scales per `user`, `paid_user`, or another canonical subject. A cadence driver cannot omit its time denominator. +Every present `per` denominator must be greater than zero. + +Variable drivers may carry an explicit `normal`, `uniform`, or `exponential` +distribution. Distribution-specific parameters preserve the stochastic basis +used by stress forecasts instead of silently substituting deterministic +`units_per_user`. Units use lowercase canonical identifiers such as `request`, `command`, `token`, `gibibyte`, `user`, `worker`, `hour`, or `month`. Ambiguous storage @@ -103,6 +109,7 @@ Rules: Provider-catalog evidence additionally requires `refresh_owner`, `refresh_cadence`, and `stale_after`, and cannot claim high confidence. +Template evidence also cannot claim high confidence. ## Current Scenario Compatibility @@ -118,6 +125,7 @@ Mapping rules: | `fixed_costs[].period` | explicit legacy-normalized commitment count and time basis | | `variable_costs[].cost_per_unit` | unit-price amount | | `variable_costs[].units_per_user` | quantity per one `user` | +| `variable_costs[].distribution` and parameters | explicit v1 stochastic distribution | | absent variable unit | caller must supply explicit name-to-unit mapping | | absent scenario currency/window | caller must supply both | | absent source | low-confidence `legacy_scenario`; never measured | diff --git a/pkg/contracts/cost/v1/legacy.go b/pkg/contracts/cost/v1/legacy.go index 9f883f0..f293800 100644 --- a/pkg/contracts/cost/v1/legacy.go +++ b/pkg/contracts/cost/v1/legacy.go @@ -80,9 +80,10 @@ func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapp Amount: Money{Amount: cost.CostPerUnit, Currency: mapping.Currency}, Per: Quantity{Value: 1, Unit: unit}, }, - Window: mapping.Window, - Dimensions: Dimensions{Workload: cost.Name}, - Evidence: legacyEvidence(cost.Source, mapping), + Window: mapping.Window, + Dimensions: Dimensions{Workload: cost.Name}, + Evidence: legacyEvidence(cost.Source, mapping), + Distribution: legacyDistribution(cost), } if err := driver.Validate(); err != nil { return nil, fmt.Errorf("variable cost %q: %w", cost.Name, err) @@ -92,6 +93,20 @@ func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapp return drivers, nil } +func legacyDistribution(cost types.VariableCost) *Distribution { + if cost.Distribution == "" { + return nil + } + return &Distribution{ + Type: DistributionType(cost.Distribution), + Mean: cost.Mean, + StdDev: cost.StdDev, + Min: cost.Min, + Max: cost.Max, + Rate: cost.Rate, + } +} + func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { evidence := Evidence{ Kind: EvidencePredicted, diff --git a/pkg/contracts/cost/v1/legacy_test.go b/pkg/contracts/cost/v1/legacy_test.go index ee016ec..ebe6a25 100644 --- a/pkg/contracts/cost/v1/legacy_test.go +++ b/pkg/contracts/cost/v1/legacy_test.go @@ -42,12 +42,40 @@ func TestLegacyScenarioMapsForwardWithoutChangingCosts(t *testing.T) { require.Equal(t, expected.Unit, drivers[i].Quantity.Unit) require.NotNil(t, drivers[i].Per) require.Equal(t, expected.PerUnit, drivers[i].Per.Unit) + if expected.Kind == v1.DriverVariable { + require.NotNil(t, drivers[i].Distribution) + } } after := cost.NewCostEngine(cfg).CalculateTotalCosts(100, 1) require.Equal(t, before, after) } +func TestLegacyMappingPreservesStochasticVariableBasis(t *testing.T) { + rate := 0.001 + drivers, err := v1.MapLegacyCosts(nil, []types.VariableCost{{ + Name: "LLM Tokens", + CostPerUnit: 0.01, + UnitsPerUser: 5000, + Distribution: types.DistExponential, + Rate: &rate, + }}, v1.LegacyMapping{ + ArtifactIdentity: "stress.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + VariableUnits: map[string]string{"LLM Tokens": "token"}, + }) + require.NoError(t, err) + require.NotNil(t, drivers[0].Distribution) + require.Equal(t, v1.DistributionExponential, drivers[0].Distribution.Type) + require.Equal(t, rate, *drivers[0].Distribution.Rate) + require.NotEqual(t, drivers[0].Quantity.Value, 1/rate) +} + func TestLegacyMappingFailsClosedWithoutVariableUnit(t *testing.T) { data, err := os.ReadFile(filepath.Join(repoRoot(t), "test", "fixtures", "valid_config.yml")) require.NoError(t, err) diff --git a/pkg/contracts/cost/v1/types.go b/pkg/contracts/cost/v1/types.go index 81dcdd6..ac8ba82 100644 --- a/pkg/contracts/cost/v1/types.go +++ b/pkg/contracts/cost/v1/types.go @@ -55,6 +55,25 @@ const ( ConfidenceHigh Confidence = "high" ) +type DistributionType string + +const ( + DistributionNormal DistributionType = "normal" + DistributionUniform DistributionType = "uniform" + DistributionExponential DistributionType = "exponential" +) + +// Distribution preserves the stochastic workload basis used by legacy stress +// simulations. Parameters are distribution-specific. +type Distribution struct { + Type DistributionType `json:"type" yaml:"type"` + Mean *float64 `json:"mean,omitempty" yaml:"mean,omitempty"` + StdDev *float64 `json:"stddev,omitempty" yaml:"stddev,omitempty"` + Min *float64 `json:"min,omitempty" yaml:"min,omitempty"` + Max *float64 `json:"max,omitempty" yaml:"max,omitempty"` + Rate *float64 `json:"rate,omitempty" yaml:"rate,omitempty"` +} + type Quantity struct { Value float64 `json:"value" yaml:"value"` Unit string `json:"unit" yaml:"unit"` @@ -104,16 +123,17 @@ type Evidence struct { } type CostDriver struct { - SchemaVersion string `json:"schema_version" yaml:"schema_version"` - ID string `json:"id" yaml:"id"` - Name string `json:"name" yaml:"name"` - Kind DriverKind `json:"kind" yaml:"kind"` - Quantity Quantity `json:"quantity" yaml:"quantity"` - Per *Quantity `json:"per,omitempty" yaml:"per,omitempty"` - UnitPrice UnitPrice `json:"unit_price" yaml:"unit_price"` - Window TimeWindow `json:"window" yaml:"window"` - Dimensions Dimensions `json:"dimensions" yaml:"dimensions"` - Evidence Evidence `json:"evidence" yaml:"evidence"` + SchemaVersion string `json:"schema_version" yaml:"schema_version"` + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + Kind DriverKind `json:"kind" yaml:"kind"` + Quantity Quantity `json:"quantity" yaml:"quantity"` + Per *Quantity `json:"per,omitempty" yaml:"per,omitempty"` + UnitPrice UnitPrice `json:"unit_price" yaml:"unit_price"` + Window TimeWindow `json:"window" yaml:"window"` + Dimensions Dimensions `json:"dimensions" yaml:"dimensions"` + Evidence Evidence `json:"evidence" yaml:"evidence"` + Distribution *Distribution `json:"distribution,omitempty" yaml:"distribution,omitempty"` } type ClaimEvidence struct { diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go index 4e03789..2374c99 100644 --- a/pkg/contracts/cost/v1/validate.go +++ b/pkg/contracts/cost/v1/validate.go @@ -39,8 +39,8 @@ func (d CostDriver) Validate() error { if err := d.Per.Validate(); err != nil { return fmt.Errorf("per: %w", err) } - if (d.Kind == DriverVariable || d.Kind == DriverCadence) && d.Per.Value <= 0 { - return fmt.Errorf("%s driver per.value must be greater than zero", d.Kind) + if d.Per.Value <= 0 { + return errors.New("per.value must be greater than zero") } } if err := d.UnitPrice.Validate(); err != nil { @@ -58,6 +58,37 @@ func (d CostDriver) Validate() error { if err := d.Evidence.Validate(); err != nil { return fmt.Errorf("evidence: %w", err) } + if d.Distribution != nil { + if d.Kind != DriverVariable { + return errors.New("distribution is only valid for variable drivers") + } + if err := d.Distribution.Validate(); err != nil { + return fmt.Errorf("distribution: %w", err) + } + } + return nil +} + +func (d Distribution) Validate() error { + validPositive := func(value *float64) bool { + return value != nil && !math.IsNaN(*value) && !math.IsInf(*value, 0) && *value > 0 + } + switch d.Type { + case DistributionNormal: + if !validPositive(d.Mean) || !validPositive(d.StdDev) { + return errors.New("normal distribution requires positive mean and stddev") + } + case DistributionUniform: + if !validPositive(d.Min) || !validPositive(d.Max) || *d.Min >= *d.Max { + return errors.New("uniform distribution requires positive min less than max") + } + case DistributionExponential: + if !validPositive(d.Rate) { + return errors.New("exponential distribution requires positive rate") + } + default: + return fmt.Errorf("unsupported distribution type %q", d.Type) + } return nil } @@ -221,6 +252,9 @@ func (e Evidence) Validate() error { return errors.New("provider_catalog evidence cannot claim high confidence") } } + if e.Source.Type == SourceTemplate && e.Confidence == ConfidenceHigh { + return errors.New("template evidence cannot claim high confidence") + } if e.Measurement == MeasurementMeasured && e.Source.Type != SourceTelemetry && e.Source.Type != SourceRuntimeLedger && e.Source.Type != SourceInvoice { return errors.New("measured evidence requires telemetry, runtime_ledger, or invoice source") diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go index dac4fef..deb924b 100644 --- a/pkg/contracts/cost/v1/validate_test.go +++ b/pkg/contracts/cost/v1/validate_test.go @@ -55,6 +55,21 @@ func TestScalingDriversRequirePerBasis(t *testing.T) { } } +func TestEveryPresentPerBasisMustBePositive(t *testing.T) { + for _, kind := range []v1.DriverKind{ + v1.DriverFixed, + v1.DriverVariable, + v1.DriverCadence, + v1.DriverConcurrency, + v1.DriverUptime, + } { + driver := validDriver() + driver.Kind = kind + driver.Per = &v1.Quantity{Value: 0, Unit: "user"} + require.ErrorContains(t, driver.Validate(), "per.value must be greater than zero") + } +} + func TestEvidenceDoesNotTreatProvenanceAsMeasurement(t *testing.T) { evidence := validEvidence() evidence.Measurement = v1.MeasurementMeasured @@ -124,6 +139,13 @@ func TestProviderCatalogRequiresStalePolicyAndCapsConfidence(t *testing.T) { require.NoError(t, evidence.Validate()) } +func TestTemplateEvidenceCannotClaimHighConfidence(t *testing.T) { + evidence := validEvidence() + evidence.Source.Type = v1.SourceTemplate + evidence.Confidence = v1.ConfidenceHigh + require.ErrorContains(t, evidence.Validate(), "template evidence cannot claim high confidence") +} + func TestObservationRejectsDuplicateDriverIDs(t *testing.T) { var fixture struct { Observation v1.CostObservation `json:"observation"` diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json index df5c695..f85fd1c 100644 --- a/schemas/cost-contract/v1/common.schema.json +++ b/schemas/cost-contract/v1/common.schema.json @@ -59,6 +59,33 @@ "workload": { "type": "string", "minLength": 1 } } }, + "distribution": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { "enum": ["normal", "uniform", "exponential"] }, + "mean": { "type": "number", "exclusiveMinimum": 0 }, + "stddev": { "type": "number", "exclusiveMinimum": 0 }, + "min": { "type": "number", "exclusiveMinimum": 0 }, + "max": { "type": "number", "exclusiveMinimum": 0 }, + "rate": { "type": "number", "exclusiveMinimum": 0 } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "normal" } } }, + "then": { "required": ["mean", "stddev"] } + }, + { + "if": { "properties": { "type": { "const": "uniform" } } }, + "then": { "required": ["min", "max"] } + }, + { + "if": { "properties": { "type": { "const": "exponential" } } }, + "then": { "required": ["rate"] } + } + ] + }, "source": { "type": "object", "additionalProperties": false, @@ -135,6 +162,24 @@ } } }, + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "template" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "confidence": { "enum": ["low", "medium"] } + } + } + }, { "if": { "properties": { diff --git a/schemas/cost-contract/v1/cost-driver.schema.json b/schemas/cost-contract/v1/cost-driver.schema.json index 44e0bc1..d56f1ec 100644 --- a/schemas/cost-contract/v1/cost-driver.schema.json +++ b/schemas/cost-contract/v1/cost-driver.schema.json @@ -29,7 +29,10 @@ "$ref": "common.schema.json#/$defs/quantity" }, "per": { - "$ref": "common.schema.json#/$defs/quantity" + "allOf": [ + { "$ref": "common.schema.json#/$defs/quantity" }, + { "properties": { "value": { "exclusiveMinimum": 0 } } } + ] }, "unit_price": { "$ref": "common.schema.json#/$defs/unit_price" @@ -42,6 +45,9 @@ }, "evidence": { "$ref": "common.schema.json#/$defs/evidence" + }, + "distribution": { + "$ref": "common.schema.json#/$defs/distribution" } }, "allOf": [ @@ -53,14 +59,16 @@ "required": ["kind"] }, "then": { - "required": ["per"], + "required": ["per"] + } + }, + { + "if": { + "required": ["distribution"] + }, + "then": { "properties": { - "per": { - "allOf": [ - { "$ref": "common.schema.json#/$defs/quantity" }, - { "properties": { "value": { "exclusiveMinimum": 0 } } } - ] - } + "kind": { "const": "variable" } } } } From 906a345677651a24b62eb30860fda6abd793b199 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 10:38:27 -0700 Subject: [PATCH 05/12] fix: harden legacy contract compatibility --- docs/contracts/cost-contract-v1.md | 4 ++ pkg/contracts/cost/v1/legacy.go | 30 +++++++++++-- pkg/contracts/cost/v1/legacy_test.go | 43 +++++++++++++++++++ pkg/contracts/cost/v1/validate.go | 32 +++++++++++--- pkg/contracts/cost/v1/validate_test.go | 27 ++++++++++++ schemas/cost-contract/v1/common.schema.json | 2 +- .../cost-contract/v1/cost-driver.schema.json | 19 ++++++++ 7 files changed, 147 insertions(+), 10 deletions(-) diff --git a/docs/contracts/cost-contract-v1.md b/docs/contracts/cost-contract-v1.md index 407ff47..651b95a 100644 --- a/docs/contracts/cost-contract-v1.md +++ b/docs/contracts/cost-contract-v1.md @@ -41,6 +41,8 @@ Every driver requires: driver cannot omit whether it scales per `user`, `paid_user`, or another canonical subject. A cadence driver cannot omit its time denominator. Every present `per` denominator must be greater than zero. +Cadence denominators use `second`, `minute`, `hour`, `day`, `week`, `month`, +or `year`. Variable drivers may carry an explicit `normal`, `uniform`, or `exponential` distribution. Distribution-specific parameters preserve the stochastic basis @@ -136,6 +138,8 @@ Legacy normalization details: uses a fixed 30-day simulated month; - monthly fixed costs become one commitment per month; - yearly fixed costs become one commitment per year; +- legacy driver IDs derive from cost kind and name, so harmless list reordering + does not break observation bindings; - `user_scope: paid_users` maps to `paid_user`; other legacy variable costs map to `user`. diff --git a/pkg/contracts/cost/v1/legacy.go b/pkg/contracts/cost/v1/legacy.go index f293800..5c75f60 100644 --- a/pkg/contracts/cost/v1/legacy.go +++ b/pkg/contracts/cost/v1/legacy.go @@ -1,6 +1,7 @@ package v1 import ( + "crypto/sha256" "errors" "fmt" "strings" @@ -34,14 +35,20 @@ func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapp } drivers := make([]CostDriver, 0, len(fixed)+len(variable)) - for i, cost := range fixed { + seenIDs := make(map[string]string, len(fixed)+len(variable)) + for _, cost := range fixed { quantity, periodUnit, err := legacyFixedBasis(cost.Period) if err != nil { return nil, fmt.Errorf("fixed cost %q: %w", cost.Name, err) } + id := stableLegacyID("fixed", cost.Name) + if prior, exists := seenIDs[id]; exists { + return nil, fmt.Errorf("legacy costs %q and %q have ambiguous stable identity", prior, cost.Name) + } + seenIDs[id] = cost.Name driver := CostDriver{ SchemaVersion: SchemaVersion, - ID: fmt.Sprintf("legacy-fixed-%d", i+1), + ID: id, Name: cost.Name, Kind: DriverFixed, Quantity: Quantity{Value: quantity, Unit: "commitment"}, @@ -60,7 +67,7 @@ func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapp drivers = append(drivers, driver) } - for i, cost := range variable { + for _, cost := range variable { unit := mapping.VariableUnits[cost.Name] if err := validateUnit(unit); err != nil { return nil, fmt.Errorf("variable cost %q unit: %w", cost.Name, err) @@ -69,9 +76,14 @@ func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapp if types.NormalizeVariableCostUserScope(cost.UserScope) == types.UserScopePaidUsers { subjectUnit = "paid_user" } + id := stableLegacyID("variable", cost.Name) + if prior, exists := seenIDs[id]; exists { + return nil, fmt.Errorf("legacy costs %q and %q have ambiguous stable identity", prior, cost.Name) + } + seenIDs[id] = cost.Name driver := CostDriver{ SchemaVersion: SchemaVersion, - ID: fmt.Sprintf("legacy-variable-%d", i+1), + ID: id, Name: cost.Name, Kind: DriverVariable, Quantity: Quantity{Value: cost.UnitsPerUser, Unit: unit}, @@ -93,6 +105,11 @@ func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapp return drivers, nil } +func stableLegacyID(kind, name string) string { + digest := sha256.Sum256([]byte(kind + "\x00" + name)) + return fmt.Sprintf("legacy-%s-%x", kind, digest[:8]) +} + func legacyDistribution(cost types.VariableCost) *Distribution { if cost.Distribution == "" { return nil @@ -145,6 +162,11 @@ func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { if strings.TrimSpace(source.Note) != "" { evidence.ConfidenceRationale = source.Note } + if evidence.Source.Type == SourceTemplate && evidence.Confidence == ConfidenceHigh { + evidence.Confidence = ConfidenceMedium + evidence.ConfidenceRationale += " Legacy template confidence capped at medium by v1." + evidence.ConfidenceRationale = strings.TrimSpace(evidence.ConfidenceRationale) + } return evidence } diff --git a/pkg/contracts/cost/v1/legacy_test.go b/pkg/contracts/cost/v1/legacy_test.go index ebe6a25..c22aa67 100644 --- a/pkg/contracts/cost/v1/legacy_test.go +++ b/pkg/contracts/cost/v1/legacy_test.go @@ -76,6 +76,49 @@ func TestLegacyMappingPreservesStochasticVariableBasis(t *testing.T) { require.NotEqual(t, drivers[0].Quantity.Value, 1/rate) } +func TestLegacyMappingUsesStableIDsAcrossReordering(t *testing.T) { + first := types.FixedCost{Name: "Database", Amount: 20, Period: types.PeriodMonthly} + second := types.FixedCost{Name: "Worker", Amount: 30, Period: types.PeriodMonthly} + mapping := v1.LegacyMapping{ + ArtifactIdentity: "stable.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + } + original, err := v1.MapLegacyCosts([]types.FixedCost{first, second}, nil, mapping) + require.NoError(t, err) + reordered, err := v1.MapLegacyCosts([]types.FixedCost{second, first}, nil, mapping) + require.NoError(t, err) + require.Equal(t, original[0].ID, reordered[1].ID) + require.Equal(t, original[1].ID, reordered[0].ID) +} + +func TestLegacyMappingCapsHighConfidenceTemplate(t *testing.T) { + drivers, err := v1.MapLegacyCosts([]types.FixedCost{{ + Name: "Template database", + Amount: 20, + Period: types.PeriodMonthly, + Source: &types.CostSource{ + Type: types.CostSourceTemplate, + Confidence: types.CostSourceConfidenceHigh, + }, + }}, nil, v1.LegacyMapping{ + ArtifactIdentity: "template.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + }) + require.NoError(t, err) + require.Equal(t, v1.SourceTemplate, drivers[0].Evidence.Source.Type) + require.Equal(t, v1.ConfidenceMedium, drivers[0].Evidence.Confidence) +} + func TestLegacyMappingFailsClosedWithoutVariableUnit(t *testing.T) { data, err := os.ReadFile(filepath.Join(repoRoot(t), "test", "fixtures", "valid_config.yml")) require.NoError(t, err) diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go index 2374c99..a3c0e90 100644 --- a/pkg/contracts/cost/v1/validate.go +++ b/pkg/contracts/cost/v1/validate.go @@ -42,6 +42,9 @@ func (d CostDriver) Validate() error { if d.Per.Value <= 0 { return errors.New("per.value must be greater than zero") } + if d.Kind == DriverCadence && !isTimeUnit(d.Per.Unit) { + return errors.New("cadence driver per.unit must be a canonical time unit") + } } if err := d.UnitPrice.Validate(); err != nil { return fmt.Errorf("unit_price: %w", err) @@ -79,8 +82,9 @@ func (d Distribution) Validate() error { return errors.New("normal distribution requires positive mean and stddev") } case DistributionUniform: - if !validPositive(d.Min) || !validPositive(d.Max) || *d.Min >= *d.Max { - return errors.New("uniform distribution requires positive min less than max") + if d.Min == nil || math.IsNaN(*d.Min) || math.IsInf(*d.Min, 0) || *d.Min < 0 || + !validPositive(d.Max) || *d.Min >= *d.Max { + return errors.New("uniform distribution requires non-negative min less than positive max") } case DistributionExponential: if !validPositive(d.Rate) { @@ -248,6 +252,11 @@ func (e Evidence) Validate() error { if !validCaptureTime(e.Source.StaleAfter) { return errors.New("source.stale_after must be an RFC3339 timestamp or ISO date") } + capturedAt, _ := parseCaptureTime(e.Source.CapturedAt) + staleAfter, _ := parseCaptureTime(e.Source.StaleAfter) + if !capturedAt.Before(staleAfter) { + return errors.New("source.stale_after must be after source.captured_at") + } if e.Confidence == ConfidenceHigh { return errors.New("provider_catalog evidence cannot claim high confidence") } @@ -329,11 +338,24 @@ func validateID(id string) error { } func validCaptureTime(value string) bool { - if _, err := time.Parse(time.RFC3339, value); err == nil { + _, err := parseCaptureTime(value) + return err == nil +} + +func parseCaptureTime(value string) (time.Time, error) { + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, nil + } + return time.Parse(time.DateOnly, value) +} + +func isTimeUnit(unit string) bool { + switch unit { + case "second", "minute", "hour", "day", "week", "month", "year": return true + default: + return false } - _, err := time.Parse(time.DateOnly, value) - return err == nil } func validDriverKind(value DriverKind) bool { diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go index deb924b..286f2bf 100644 --- a/pkg/contracts/cost/v1/validate_test.go +++ b/pkg/contracts/cost/v1/validate_test.go @@ -21,6 +21,9 @@ func TestAllDriverKindsValidate(t *testing.T) { t.Run(string(kind), func(t *testing.T) { driver := validDriver() driver.Kind = kind + if kind == v1.DriverCadence { + driver.Per = &v1.Quantity{Value: 1, Unit: "minute"} + } require.NoError(t, driver.Validate()) }) } @@ -70,6 +73,27 @@ func TestEveryPresentPerBasisMustBePositive(t *testing.T) { } } +func TestCadenceRequiresTimeBasis(t *testing.T) { + driver := validDriver() + driver.Kind = v1.DriverCadence + driver.Per = &v1.Quantity{Value: 1, Unit: "user"} + require.ErrorContains(t, driver.Validate(), "canonical time unit") + + driver.Per.Unit = "minute" + require.NoError(t, driver.Validate()) +} + +func TestUniformDistributionAllowsZeroMinimum(t *testing.T) { + minimum, maximum := 0.0, 10.0 + driver := validDriver() + driver.Distribution = &v1.Distribution{ + Type: v1.DistributionUniform, + Min: &minimum, + Max: &maximum, + } + require.NoError(t, driver.Validate()) +} + func TestEvidenceDoesNotTreatProvenanceAsMeasurement(t *testing.T) { evidence := validEvidence() evidence.Measurement = v1.MeasurementMeasured @@ -137,6 +161,9 @@ func TestProviderCatalogRequiresStalePolicyAndCapsConfidence(t *testing.T) { evidence.Confidence = v1.ConfidenceMedium require.NoError(t, evidence.Validate()) + + evidence.Source.StaleAfter = "2026-07-31" + require.ErrorContains(t, evidence.Validate(), "must be after") } func TestTemplateEvidenceCannotClaimHighConfidence(t *testing.T) { diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json index f85fd1c..de54699 100644 --- a/schemas/cost-contract/v1/common.schema.json +++ b/schemas/cost-contract/v1/common.schema.json @@ -67,7 +67,7 @@ "type": { "enum": ["normal", "uniform", "exponential"] }, "mean": { "type": "number", "exclusiveMinimum": 0 }, "stddev": { "type": "number", "exclusiveMinimum": 0 }, - "min": { "type": "number", "exclusiveMinimum": 0 }, + "min": { "type": "number", "minimum": 0 }, "max": { "type": "number", "exclusiveMinimum": 0 }, "rate": { "type": "number", "exclusiveMinimum": 0 } }, diff --git a/schemas/cost-contract/v1/cost-driver.schema.json b/schemas/cost-contract/v1/cost-driver.schema.json index d56f1ec..ae4811e 100644 --- a/schemas/cost-contract/v1/cost-driver.schema.json +++ b/schemas/cost-contract/v1/cost-driver.schema.json @@ -62,6 +62,25 @@ "required": ["per"] } }, + { + "if": { + "properties": { + "kind": { "const": "cadence" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "per": { + "properties": { + "unit": { + "enum": ["second", "minute", "hour", "day", "week", "month", "year"] + } + } + } + } + } + }, { "if": { "required": ["distribution"] From d0f04304d2618dcf755af5c8b9071cc5fcf5c68f Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 11:00:02 -0700 Subject: [PATCH 06/12] fix: enforce contract time boundaries --- docs/contracts/cost-contract-v1.md | 3 ++ pkg/contracts/cost/v1/legacy.go | 22 ++++++++----- pkg/contracts/cost/v1/legacy_test.go | 24 ++++++++++++++ pkg/contracts/cost/v1/validate.go | 27 ++++++++++++++++ pkg/contracts/cost/v1/validate_test.go | 31 +++++++++++++++++++ .../cost-contract/v1/cost-driver.schema.json | 19 ++++++++++++ 6 files changed, 119 insertions(+), 7 deletions(-) diff --git a/docs/contracts/cost-contract-v1.md b/docs/contracts/cost-contract-v1.md index 651b95a..04d8a35 100644 --- a/docs/contracts/cost-contract-v1.md +++ b/docs/contracts/cost-contract-v1.md @@ -43,6 +43,7 @@ canonical subject. A cadence driver cannot omit its time denominator. Every present `per` denominator must be greater than zero. Cadence denominators use `second`, `minute`, `hour`, `day`, `week`, `month`, or `year`. +Uptime quantities use the same canonical time units. Variable drivers may carry an explicit `normal`, `uniform`, or `exponential` distribution. Distribution-specific parameters preserve the stochastic basis @@ -111,6 +112,8 @@ Rules: Provider-catalog evidence additionally requires `refresh_owner`, `refresh_cadence`, and `stale_after`, and cannot claim high confidence. +Catalog evidence stale before the enclosing driver or observation window fails +closed. Template evidence also cannot claim high confidence. ## Current Scenario Compatibility diff --git a/pkg/contracts/cost/v1/legacy.go b/pkg/contracts/cost/v1/legacy.go index 5c75f60..b29849e 100644 --- a/pkg/contracts/cost/v1/legacy.go +++ b/pkg/contracts/cost/v1/legacy.go @@ -145,23 +145,24 @@ func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { // evidence. if SourceType(source.Type) == SourceProviderCatalog { evidence.Source.URL = source.URL - if source.CapturedAt != "" { - evidence.Source.CapturedAt = source.CapturedAt - } + evidence.Source.CapturedAt = legacyCaptureAt(source.CapturedAt, mapping.CapturedAt) evidence.ConfidenceRationale = "Legacy provider catalog assumption mapped without v1 refresh policy or current-price claim." + if source.CapturedAt != "" && !validCaptureTime(source.CapturedAt) { + evidence.ConfidenceRationale += " Original capture text: " + source.CapturedAt + "." + } return evidence } evidence.Source.Type = SourceType(source.Type) evidence.Source.URL = source.URL - evidence.Source.CapturedAt = source.CapturedAt - if evidence.Source.CapturedAt == "" { - evidence.Source.CapturedAt = mapping.CapturedAt - } + evidence.Source.CapturedAt = legacyCaptureAt(source.CapturedAt, mapping.CapturedAt) evidence.Confidence = Confidence(source.Confidence) if strings.TrimSpace(source.Note) != "" { evidence.ConfidenceRationale = source.Note } + if source.CapturedAt != "" && !validCaptureTime(source.CapturedAt) { + evidence.ConfidenceRationale += " Original capture text: " + source.CapturedAt + "." + } if evidence.Source.Type == SourceTemplate && evidence.Confidence == ConfidenceHigh { evidence.Confidence = ConfidenceMedium evidence.ConfidenceRationale += " Legacy template confidence capped at medium by v1." @@ -170,6 +171,13 @@ func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { return evidence } +func legacyCaptureAt(sourceCapture, fallback string) string { + if validCaptureTime(sourceCapture) { + return sourceCapture + } + return fallback +} + func legacyFixedBasis(period types.CostPeriod) (float64, string, error) { switch period { case types.PeriodDaily: diff --git a/pkg/contracts/cost/v1/legacy_test.go b/pkg/contracts/cost/v1/legacy_test.go index c22aa67..632e45f 100644 --- a/pkg/contracts/cost/v1/legacy_test.go +++ b/pkg/contracts/cost/v1/legacy_test.go @@ -119,6 +119,30 @@ func TestLegacyMappingCapsHighConfidenceTemplate(t *testing.T) { require.Equal(t, v1.ConfidenceMedium, drivers[0].Evidence.Confidence) } +func TestLegacyMappingFallsBackFromFreeFormCaptureText(t *testing.T) { + drivers, err := v1.MapLegacyCosts([]types.FixedCost{{ + Name: "Database", + Amount: 20, + Period: types.PeriodMonthly, + Source: &types.CostSource{ + Type: types.CostSourceUserSupplied, + Confidence: types.CostSourceConfidenceMedium, + CapturedAt: "May 2026", + }, + }}, nil, v1.LegacyMapping{ + ArtifactIdentity: "capture.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + }) + require.NoError(t, err) + require.Equal(t, "2026-08-01", drivers[0].Evidence.Source.CapturedAt) + require.Contains(t, drivers[0].Evidence.ConfidenceRationale, "May 2026") +} + func TestLegacyMappingFailsClosedWithoutVariableUnit(t *testing.T) { data, err := os.ReadFile(filepath.Join(repoRoot(t), "test", "fixtures", "valid_config.yml")) require.NoError(t, err) diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go index a3c0e90..0f2f2ee 100644 --- a/pkg/contracts/cost/v1/validate.go +++ b/pkg/contracts/cost/v1/validate.go @@ -35,6 +35,9 @@ func (d CostDriver) Validate() error { if err := d.Quantity.Validate(); err != nil { return fmt.Errorf("quantity: %w", err) } + if d.Kind == DriverUptime && !isTimeUnit(d.Quantity.Unit) { + return errors.New("uptime driver quantity.unit must be a canonical time unit") + } if d.Per != nil { if err := d.Per.Validate(); err != nil { return fmt.Errorf("per: %w", err) @@ -61,6 +64,9 @@ func (d CostDriver) Validate() error { if err := d.Evidence.Validate(); err != nil { return fmt.Errorf("evidence: %w", err) } + if err := validateEvidenceFreshForWindow(d.Evidence, d.Window); err != nil { + return fmt.Errorf("evidence: %w", err) + } if d.Distribution != nil { if d.Kind != DriverVariable { return errors.New("distribution is only valid for variable drivers") @@ -152,6 +158,9 @@ func (o CostObservation) Validate() error { if err := validateClaimAuthority(claim.name, claim.evidence.Source.Type); err != nil { return fmt.Errorf("evidence.%s: %w", claim.name, err) } + if err := validateEvidenceFreshForWindow(claim.evidence, o.Window); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } } if o.Evidence.TotalCost.Measurement == MeasurementDerived { expected := o.Quantity.Value * o.UnitPrice.Amount.Amount / o.UnitPrice.Per.Value @@ -319,6 +328,24 @@ func validateClaimAuthority(claim string, source SourceType) error { return nil } +func validateEvidenceFreshForWindow(evidence Evidence, window TimeWindow) error { + if evidence.Source.Type != SourceProviderCatalog { + return nil + } + staleAfter, err := parseCaptureTime(evidence.Source.StaleAfter) + if err != nil { + return errors.New("source.stale_after must be an RFC3339 timestamp or ISO date") + } + windowStart, err := time.Parse(time.RFC3339, window.Start) + if err != nil { + return errors.New("window.start must be an RFC3339 timestamp") + } + if staleAfter.Before(windowStart) { + return errors.New("provider_catalog source is stale before window start") + } + return nil +} + func validateUnit(unit string) error { if !canonicalUnitPattern.MatchString(unit) { return errors.New("unit must be a non-empty canonical lowercase identifier") diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go index 286f2bf..239aca2 100644 --- a/pkg/contracts/cost/v1/validate_test.go +++ b/pkg/contracts/cost/v1/validate_test.go @@ -24,6 +24,10 @@ func TestAllDriverKindsValidate(t *testing.T) { if kind == v1.DriverCadence { driver.Per = &v1.Quantity{Value: 1, Unit: "minute"} } + if kind == v1.DriverUptime { + driver.Quantity.Unit = "hour" + driver.UnitPrice.Per.Unit = "hour" + } require.NoError(t, driver.Validate()) }) } @@ -68,6 +72,10 @@ func TestEveryPresentPerBasisMustBePositive(t *testing.T) { } { driver := validDriver() driver.Kind = kind + if kind == v1.DriverUptime { + driver.Quantity.Unit = "hour" + driver.UnitPrice.Per.Unit = "hour" + } driver.Per = &v1.Quantity{Value: 0, Unit: "user"} require.ErrorContains(t, driver.Validate(), "per.value must be greater than zero") } @@ -83,6 +91,16 @@ func TestCadenceRequiresTimeBasis(t *testing.T) { require.NoError(t, driver.Validate()) } +func TestUptimeRequiresTimeQuantity(t *testing.T) { + driver := validDriver() + driver.Kind = v1.DriverUptime + require.ErrorContains(t, driver.Validate(), "canonical time unit") + + driver.Quantity.Unit = "hour" + driver.UnitPrice.Per.Unit = "hour" + require.NoError(t, driver.Validate()) +} + func TestUniformDistributionAllowsZeroMinimum(t *testing.T) { minimum, maximum := 0.0, 10.0 driver := validDriver() @@ -166,6 +184,19 @@ func TestProviderCatalogRequiresStalePolicyAndCapsConfidence(t *testing.T) { require.ErrorContains(t, evidence.Validate(), "must be after") } +func TestDriverRejectsCatalogStaleBeforeWindow(t *testing.T) { + driver := validDriver() + driver.Evidence.Source.Type = v1.SourceProviderCatalog + driver.Evidence.Source.RefreshOwner = "catalog-maintainer" + driver.Evidence.Source.RefreshCadence = "30d" + driver.Evidence.Source.CapturedAt = "2026-07-01" + driver.Evidence.Source.StaleAfter = "2026-07-31" + require.ErrorContains(t, driver.Validate(), "stale before window start") + + driver.Evidence.Source.StaleAfter = "2026-08-15" + require.NoError(t, driver.Validate()) +} + func TestTemplateEvidenceCannotClaimHighConfidence(t *testing.T) { evidence := validEvidence() evidence.Source.Type = v1.SourceTemplate diff --git a/schemas/cost-contract/v1/cost-driver.schema.json b/schemas/cost-contract/v1/cost-driver.schema.json index ae4811e..cb64a47 100644 --- a/schemas/cost-contract/v1/cost-driver.schema.json +++ b/schemas/cost-contract/v1/cost-driver.schema.json @@ -62,6 +62,25 @@ "required": ["per"] } }, + { + "if": { + "properties": { + "kind": { "const": "uptime" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "quantity": { + "properties": { + "unit": { + "enum": ["second", "minute", "hour", "day", "week", "month", "year"] + } + } + } + } + } + }, { "if": { "properties": { From 0d8e17dc40307c0044b682ca111fc673d0ecdf92 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 11:14:01 -0700 Subject: [PATCH 07/12] fix: align provenance and sampler semantics --- docs/contracts/cost-contract-v1.md | 4 ++ pkg/contracts/cost/v1/legacy.go | 12 +++++- pkg/contracts/cost/v1/legacy_test.go | 46 +++++++++++++++++++++ pkg/contracts/cost/v1/types.go | 1 + pkg/contracts/cost/v1/validate.go | 6 +++ pkg/contracts/cost/v1/validate_test.go | 7 ++++ schemas/cost-contract/v1/common.schema.json | 22 +++++++++- 7 files changed, 96 insertions(+), 2 deletions(-) diff --git a/docs/contracts/cost-contract-v1.md b/docs/contracts/cost-contract-v1.md index 04d8a35..e898a16 100644 --- a/docs/contracts/cost-contract-v1.md +++ b/docs/contracts/cost-contract-v1.md @@ -49,6 +49,8 @@ Variable drivers may carry an explicit `normal`, `uniform`, or `exponential` distribution. Distribution-specific parameters preserve the stochastic basis used by stress forecasts instead of silently substituting deterministic `units_per_user`. +Legacy normal distributions also carry `floor: 0`, matching ProfitCtl's +non-negative sampler. Units use lowercase canonical identifiers such as `request`, `command`, `token`, `gibibyte`, `user`, `worker`, `hour`, or `month`. Ambiguous storage @@ -115,6 +117,8 @@ Provider-catalog evidence additionally requires `refresh_owner`, Catalog evidence stale before the enclosing driver or observation window fails closed. Template evidence also cannot claim high confidence. +Repo-detected evidence is likewise capped at medium until confirmed by user or +measurement evidence. ## Current Scenario Compatibility diff --git a/pkg/contracts/cost/v1/legacy.go b/pkg/contracts/cost/v1/legacy.go index b29849e..e89c900 100644 --- a/pkg/contracts/cost/v1/legacy.go +++ b/pkg/contracts/cost/v1/legacy.go @@ -114,7 +114,7 @@ func legacyDistribution(cost types.VariableCost) *Distribution { if cost.Distribution == "" { return nil } - return &Distribution{ + distribution := &Distribution{ Type: DistributionType(cost.Distribution), Mean: cost.Mean, StdDev: cost.StdDev, @@ -122,6 +122,11 @@ func legacyDistribution(cost types.VariableCost) *Distribution { Max: cost.Max, Rate: cost.Rate, } + if cost.Distribution == types.DistNormal { + floor := 0.0 + distribution.Floor = &floor + } + return distribution } func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { @@ -168,6 +173,11 @@ func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { evidence.ConfidenceRationale += " Legacy template confidence capped at medium by v1." evidence.ConfidenceRationale = strings.TrimSpace(evidence.ConfidenceRationale) } + if evidence.Source.Type == SourceRepoDetected && evidence.Confidence == ConfidenceHigh { + evidence.Confidence = ConfidenceMedium + evidence.ConfidenceRationale += " Legacy repo-detected confidence capped at medium by v1." + evidence.ConfidenceRationale = strings.TrimSpace(evidence.ConfidenceRationale) + } return evidence } diff --git a/pkg/contracts/cost/v1/legacy_test.go b/pkg/contracts/cost/v1/legacy_test.go index 632e45f..2380daf 100644 --- a/pkg/contracts/cost/v1/legacy_test.go +++ b/pkg/contracts/cost/v1/legacy_test.go @@ -76,6 +76,30 @@ func TestLegacyMappingPreservesStochasticVariableBasis(t *testing.T) { require.NotEqual(t, drivers[0].Quantity.Value, 1/rate) } +func TestLegacyMappingPreservesNormalNonNegativeFloor(t *testing.T) { + mean, stddev := 1.0, 10.0 + drivers, err := v1.MapLegacyCosts(nil, []types.VariableCost{{ + Name: "Bursty requests", + CostPerUnit: 0.01, + UnitsPerUser: 1, + Distribution: types.DistNormal, + Mean: &mean, + StdDev: &stddev, + }}, v1.LegacyMapping{ + ArtifactIdentity: "normal.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + VariableUnits: map[string]string{"Bursty requests": "request"}, + }) + require.NoError(t, err) + require.NotNil(t, drivers[0].Distribution.Floor) + require.Zero(t, *drivers[0].Distribution.Floor) +} + func TestLegacyMappingUsesStableIDsAcrossReordering(t *testing.T) { first := types.FixedCost{Name: "Database", Amount: 20, Period: types.PeriodMonthly} second := types.FixedCost{Name: "Worker", Amount: 30, Period: types.PeriodMonthly} @@ -119,6 +143,28 @@ func TestLegacyMappingCapsHighConfidenceTemplate(t *testing.T) { require.Equal(t, v1.ConfidenceMedium, drivers[0].Evidence.Confidence) } +func TestLegacyMappingCapsHighConfidenceRepoDetected(t *testing.T) { + drivers, err := v1.MapLegacyCosts([]types.FixedCost{{ + Name: "Detected worker", + Amount: 20, + Period: types.PeriodMonthly, + Source: &types.CostSource{ + Type: types.CostSourceRepoDetected, + Confidence: types.CostSourceConfidenceHigh, + }, + }}, nil, v1.LegacyMapping{ + ArtifactIdentity: "detected.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + }) + require.NoError(t, err) + require.Equal(t, v1.ConfidenceMedium, drivers[0].Evidence.Confidence) +} + func TestLegacyMappingFallsBackFromFreeFormCaptureText(t *testing.T) { drivers, err := v1.MapLegacyCosts([]types.FixedCost{{ Name: "Database", diff --git a/pkg/contracts/cost/v1/types.go b/pkg/contracts/cost/v1/types.go index ac8ba82..cce8b94 100644 --- a/pkg/contracts/cost/v1/types.go +++ b/pkg/contracts/cost/v1/types.go @@ -72,6 +72,7 @@ type Distribution struct { Min *float64 `json:"min,omitempty" yaml:"min,omitempty"` Max *float64 `json:"max,omitempty" yaml:"max,omitempty"` Rate *float64 `json:"rate,omitempty" yaml:"rate,omitempty"` + Floor *float64 `json:"floor,omitempty" yaml:"floor,omitempty"` } type Quantity struct { diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go index 0f2f2ee..77c371d 100644 --- a/pkg/contracts/cost/v1/validate.go +++ b/pkg/contracts/cost/v1/validate.go @@ -99,6 +99,9 @@ func (d Distribution) Validate() error { default: return fmt.Errorf("unsupported distribution type %q", d.Type) } + if d.Floor != nil && (math.IsNaN(*d.Floor) || math.IsInf(*d.Floor, 0) || *d.Floor < 0) { + return errors.New("floor must be a finite non-negative number") + } return nil } @@ -273,6 +276,9 @@ func (e Evidence) Validate() error { if e.Source.Type == SourceTemplate && e.Confidence == ConfidenceHigh { return errors.New("template evidence cannot claim high confidence") } + if e.Source.Type == SourceRepoDetected && e.Confidence == ConfidenceHigh { + return errors.New("repo_detected evidence cannot claim high confidence") + } if e.Measurement == MeasurementMeasured && e.Source.Type != SourceTelemetry && e.Source.Type != SourceRuntimeLedger && e.Source.Type != SourceInvoice { return errors.New("measured evidence requires telemetry, runtime_ledger, or invoice source") diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go index 239aca2..7f86a4b 100644 --- a/pkg/contracts/cost/v1/validate_test.go +++ b/pkg/contracts/cost/v1/validate_test.go @@ -204,6 +204,13 @@ func TestTemplateEvidenceCannotClaimHighConfidence(t *testing.T) { require.ErrorContains(t, evidence.Validate(), "template evidence cannot claim high confidence") } +func TestRepoDetectedEvidenceCannotClaimHighConfidence(t *testing.T) { + evidence := validEvidence() + evidence.Source.Type = v1.SourceRepoDetected + evidence.Confidence = v1.ConfidenceHigh + require.ErrorContains(t, evidence.Validate(), "repo_detected evidence cannot claim high confidence") +} + func TestObservationRejectsDuplicateDriverIDs(t *testing.T) { var fixture struct { Observation v1.CostObservation `json:"observation"` diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json index de54699..594032b 100644 --- a/schemas/cost-contract/v1/common.schema.json +++ b/schemas/cost-contract/v1/common.schema.json @@ -69,7 +69,8 @@ "stddev": { "type": "number", "exclusiveMinimum": 0 }, "min": { "type": "number", "minimum": 0 }, "max": { "type": "number", "exclusiveMinimum": 0 }, - "rate": { "type": "number", "exclusiveMinimum": 0 } + "rate": { "type": "number", "exclusiveMinimum": 0 }, + "floor": { "type": "number", "minimum": 0 } }, "allOf": [ { @@ -130,6 +131,7 @@ "url": { "type": "string", "format": "uri" }, "captured_at": { "type": "string", + "minLength": 1, "description": "ISO date or RFC3339 timestamp." }, "refresh_owner": { "type": "string", "minLength": 1 }, @@ -162,6 +164,24 @@ } } }, + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "repo_detected" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "confidence": { "enum": ["low", "medium"] } + } + } + }, { "if": { "properties": { From 18e75def3fdb2f732564152f0653613bf59f3e5d Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 11:28:30 -0700 Subject: [PATCH 08/12] fix: align schema and legacy sampling --- docs/contracts/cost-contract-v1.md | 4 +- pkg/contracts/cost/v1/legacy.go | 2 +- pkg/contracts/cost/v1/legacy_test.go | 31 ++++++++++-- pkg/contracts/cost/v1/validate.go | 14 ++++-- pkg/contracts/cost/v1/validate_test.go | 19 ++++++-- schemas/cost-contract/v1/common.schema.json | 52 ++++++++++++++++++--- 6 files changed, 103 insertions(+), 19 deletions(-) diff --git a/docs/contracts/cost-contract-v1.md b/docs/contracts/cost-contract-v1.md index e898a16..e03d140 100644 --- a/docs/contracts/cost-contract-v1.md +++ b/docs/contracts/cost-contract-v1.md @@ -49,8 +49,8 @@ Variable drivers may carry an explicit `normal`, `uniform`, or `exponential` distribution. Distribution-specific parameters preserve the stochastic basis used by stress forecasts instead of silently substituting deterministic `units_per_user`. -Legacy normal distributions also carry `floor: 0`, matching ProfitCtl's -non-negative sampler. +Legacy normal and uniform distributions also carry `floor: 0`, matching +ProfitCtl's non-negative sampler. Units use lowercase canonical identifiers such as `request`, `command`, `token`, `gibibyte`, `user`, `worker`, `hour`, or `month`. Ambiguous storage diff --git a/pkg/contracts/cost/v1/legacy.go b/pkg/contracts/cost/v1/legacy.go index e89c900..a232f05 100644 --- a/pkg/contracts/cost/v1/legacy.go +++ b/pkg/contracts/cost/v1/legacy.go @@ -122,7 +122,7 @@ func legacyDistribution(cost types.VariableCost) *Distribution { Max: cost.Max, Rate: cost.Rate, } - if cost.Distribution == types.DistNormal { + if cost.Distribution == types.DistNormal || cost.Distribution == types.DistUniform { floor := 0.0 distribution.Floor = &floor } diff --git a/pkg/contracts/cost/v1/legacy_test.go b/pkg/contracts/cost/v1/legacy_test.go index 2380daf..7a9dd86 100644 --- a/pkg/contracts/cost/v1/legacy_test.go +++ b/pkg/contracts/cost/v1/legacy_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/IntelIP/ProfitCtl/internal/config" - "github.com/IntelIP/ProfitCtl/internal/cost" v1 "github.com/IntelIP/ProfitCtl/pkg/contracts/cost/v1" "github.com/IntelIP/ProfitCtl/pkg/types" "github.com/stretchr/testify/require" @@ -29,7 +28,7 @@ func TestLegacyScenarioMapsForwardWithoutChangingCosts(t *testing.T) { root := repoRoot(t) cfg, err := config.ParseConfig(filepath.Join(root, fixture.SourceScenario)) require.NoError(t, err) - before := cost.NewCostEngine(cfg).CalculateTotalCosts(100, 1) + before := *cfg drivers, err := v1.MapLegacyCosts(cfg.FixedCosts, cfg.VariableCosts, fixture.Mapping) require.NoError(t, err) @@ -47,8 +46,7 @@ func TestLegacyScenarioMapsForwardWithoutChangingCosts(t *testing.T) { } } - after := cost.NewCostEngine(cfg).CalculateTotalCosts(100, 1) - require.Equal(t, before, after) + require.Equal(t, before, *cfg) } func TestLegacyMappingPreservesStochasticVariableBasis(t *testing.T) { @@ -100,6 +98,31 @@ func TestLegacyMappingPreservesNormalNonNegativeFloor(t *testing.T) { require.Zero(t, *drivers[0].Distribution.Floor) } +func TestLegacyMappingPreservesUniformNonNegativeFloor(t *testing.T) { + minimum, maximum := -10.0, 10.0 + drivers, err := v1.MapLegacyCosts(nil, []types.VariableCost{{ + Name: "Burst range", + CostPerUnit: 0.01, + UnitsPerUser: 1, + Distribution: types.DistUniform, + Min: &minimum, + Max: &maximum, + }}, v1.LegacyMapping{ + ArtifactIdentity: "uniform.yml", + CapturedAt: "2026-08-01", + Currency: "USD", + Window: v1.TimeWindow{ + Start: "2026-08-01T00:00:00Z", + End: "2026-09-01T00:00:00Z", + }, + VariableUnits: map[string]string{"Burst range": "request"}, + }) + require.NoError(t, err) + require.Equal(t, minimum, *drivers[0].Distribution.Min) + require.NotNil(t, drivers[0].Distribution.Floor) + require.Zero(t, *drivers[0].Distribution.Floor) +} + func TestLegacyMappingUsesStableIDsAcrossReordering(t *testing.T) { first := types.FixedCost{Name: "Database", Amount: 20, Period: types.PeriodMonthly} second := types.FixedCost{Name: "Worker", Amount: 30, Period: types.PeriodMonthly} diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go index 77c371d..4d71968 100644 --- a/pkg/contracts/cost/v1/validate.go +++ b/pkg/contracts/cost/v1/validate.go @@ -88,9 +88,14 @@ func (d Distribution) Validate() error { return errors.New("normal distribution requires positive mean and stddev") } case DistributionUniform: - if d.Min == nil || math.IsNaN(*d.Min) || math.IsInf(*d.Min, 0) || *d.Min < 0 || - !validPositive(d.Max) || *d.Min >= *d.Max { - return errors.New("uniform distribution requires non-negative min less than positive max") + if d.Min == nil || d.Max == nil || + math.IsNaN(*d.Min) || math.IsInf(*d.Min, 0) || + math.IsNaN(*d.Max) || math.IsInf(*d.Max, 0) || + *d.Min >= *d.Max { + return errors.New("uniform distribution requires finite min less than max") + } + if d.Floor == nil { + return errors.New("uniform distribution requires explicit non-negative floor") } case DistributionExponential: if !validPositive(d.Rate) { @@ -279,6 +284,9 @@ func (e Evidence) Validate() error { if e.Source.Type == SourceRepoDetected && e.Confidence == ConfidenceHigh { return errors.New("repo_detected evidence cannot claim high confidence") } + if e.Source.Type == SourceProfitCtlDerived && e.Measurement != MeasurementDerived { + return errors.New("profitctl_derived evidence must use derived measurement") + } if e.Measurement == MeasurementMeasured && e.Source.Type != SourceTelemetry && e.Source.Type != SourceRuntimeLedger && e.Source.Type != SourceInvoice { return errors.New("measured evidence requires telemetry, runtime_ledger, or invoice source") diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go index 7f86a4b..7aa4ecc 100644 --- a/pkg/contracts/cost/v1/validate_test.go +++ b/pkg/contracts/cost/v1/validate_test.go @@ -103,11 +103,13 @@ func TestUptimeRequiresTimeQuantity(t *testing.T) { func TestUniformDistributionAllowsZeroMinimum(t *testing.T) { minimum, maximum := 0.0, 10.0 + floor := 0.0 driver := validDriver() driver.Distribution = &v1.Distribution{ - Type: v1.DistributionUniform, - Min: &minimum, - Max: &maximum, + Type: v1.DistributionUniform, + Min: &minimum, + Max: &maximum, + Floor: &floor, } require.NoError(t, driver.Validate()) } @@ -166,6 +168,17 @@ func TestObservationValidatesDerivedTotalArithmetic(t *testing.T) { require.ErrorContains(t, fixture.Observation.Validate(), "derived total_cost") } +func TestProfitCtlDerivedEvidenceRequiresDerivedMeasurement(t *testing.T) { + var fixture struct { + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + fixture.Observation.Evidence.TotalCost.Source.Type = v1.SourceProfitCtlDerived + fixture.Observation.Evidence.TotalCost.Source.ArtifactIdentity = "profitctl://derived/total" + fixture.Observation.Evidence.TotalCost.Measurement = v1.MeasurementDeclared + require.ErrorContains(t, fixture.Observation.Validate(), "must use derived measurement") +} + func TestProviderCatalogRequiresStalePolicyAndCapsConfidence(t *testing.T) { evidence := validEvidence() evidence.Source.Type = v1.SourceProviderCatalog diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json index 594032b..64b6a3e 100644 --- a/schemas/cost-contract/v1/common.schema.json +++ b/schemas/cost-contract/v1/common.schema.json @@ -67,8 +67,8 @@ "type": { "enum": ["normal", "uniform", "exponential"] }, "mean": { "type": "number", "exclusiveMinimum": 0 }, "stddev": { "type": "number", "exclusiveMinimum": 0 }, - "min": { "type": "number", "minimum": 0 }, - "max": { "type": "number", "exclusiveMinimum": 0 }, + "min": { "type": "number" }, + "max": { "type": "number" }, "rate": { "type": "number", "exclusiveMinimum": 0 }, "floor": { "type": "number", "minimum": 0 } }, @@ -79,7 +79,7 @@ }, { "if": { "properties": { "type": { "const": "uniform" } } }, - "then": { "required": ["min", "max"] } + "then": { "required": ["min", "max", "floor"] } }, { "if": { "properties": { "type": { "const": "exponential" } } }, @@ -130,14 +130,19 @@ "artifact_identity": { "type": "string", "minLength": 1 }, "url": { "type": "string", "format": "uri" }, "captured_at": { - "type": "string", - "minLength": 1, + "anyOf": [ + { "type": "string", "format": "date" }, + { "type": "string", "format": "date-time" } + ], "description": "ISO date or RFC3339 timestamp." }, "refresh_owner": { "type": "string", "minLength": 1 }, "refresh_cadence": { "type": "string", "minLength": 1 }, "stale_after": { - "type": "string", + "anyOf": [ + { "type": "string", "format": "date" }, + { "type": "string", "format": "date-time" } + ], "description": "ISO date or RFC3339 timestamp." } } @@ -164,6 +169,41 @@ } } }, + { + "if": { + "properties": { + "measurement": { "const": "synthetic" } + }, + "required": ["measurement"] + }, + "then": { + "properties": { + "source": { + "properties": { + "type": { "const": "synthetic_fixture" } + } + } + } + } + }, + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "profitctl_derived" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "measurement": { "const": "derived" } + } + } + }, { "if": { "properties": { From 53221f11d109098453f1051663ab86a1076b9637 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 11:41:50 -0700 Subject: [PATCH 09/12] fix: align schema evidence rules --- schemas/cost-contract/v1/common.schema.json | 70 +++++++++++++++++-- .../cost-contract/v1/cost-driver.schema.json | 2 +- .../v1/cost-observation.schema.json | 68 +++++++++++++++++- 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json index 64b6a3e..634c953 100644 --- a/schemas/cost-contract/v1/common.schema.json +++ b/schemas/cost-contract/v1/common.schema.json @@ -56,7 +56,7 @@ "service": { "type": "string" }, "region": { "type": "string" }, "tier": { "type": "string" }, - "workload": { "type": "string", "minLength": 1 } + "workload": { "type": "string", "pattern": ".*\\S.*" } } }, "distribution": { @@ -127,7 +127,7 @@ "legacy_scenario" ] }, - "artifact_identity": { "type": "string", "minLength": 1 }, + "artifact_identity": { "type": "string", "pattern": ".*\\S.*" }, "url": { "type": "string", "format": "uri" }, "captured_at": { "anyOf": [ @@ -136,8 +136,8 @@ ], "description": "ISO date or RFC3339 timestamp." }, - "refresh_owner": { "type": "string", "minLength": 1 }, - "refresh_cadence": { "type": "string", "minLength": 1 }, + "refresh_owner": { "type": "string", "pattern": ".*\\S.*" }, + "refresh_cadence": { "type": "string", "pattern": ".*\\S.*" }, "stale_after": { "anyOf": [ { "type": "string", "format": "date" }, @@ -169,6 +169,66 @@ } } }, + { + "if": { + "properties": { + "measurement": { "const": "measured" } + }, + "required": ["measurement"] + }, + "then": { + "properties": { + "source": { + "properties": { + "type": { "enum": ["telemetry", "runtime_ledger", "invoice"] } + } + } + } + } + }, + { + "if": { + "properties": { + "kind": { "const": "observed" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "measurement": { "enum": ["measured", "synthetic"] } + } + } + }, + { + "if": { + "properties": { + "kind": { "const": "predicted" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "measurement": { "enum": ["synthetic", "derived", "declared"] } + } + } + }, + { + "if": { + "properties": { + "kind": { "const": "user_supplied" } + }, + "required": ["kind"] + }, + "then": { + "properties": { + "source": { + "properties": { + "type": { "const": "user_supplied" } + } + } + } + } + }, { "if": { "properties": { @@ -275,7 +335,7 @@ }, "source": { "$ref": "#/$defs/source" }, "confidence": { "enum": ["low", "medium", "high"] }, - "confidence_rationale": { "type": "string", "minLength": 1 } + "confidence_rationale": { "type": "string", "pattern": ".*\\S.*" } } } } diff --git a/schemas/cost-contract/v1/cost-driver.schema.json b/schemas/cost-contract/v1/cost-driver.schema.json index cb64a47..fd09e68 100644 --- a/schemas/cost-contract/v1/cost-driver.schema.json +++ b/schemas/cost-contract/v1/cost-driver.schema.json @@ -21,7 +21,7 @@ "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,127}$" }, - "name": { "type": "string", "minLength": 1 }, + "name": { "type": "string", "pattern": ".*\\S.*" }, "kind": { "enum": ["fixed", "variable", "cadence", "concurrency", "uptime"] }, diff --git a/schemas/cost-contract/v1/cost-observation.schema.json b/schemas/cost-contract/v1/cost-observation.schema.json index 20378c9..01186f6 100644 --- a/schemas/cost-contract/v1/cost-observation.schema.json +++ b/schemas/cost-contract/v1/cost-observation.schema.json @@ -51,13 +51,75 @@ "required": ["quantity", "unit_price", "total_cost"], "properties": { "quantity": { - "$ref": "common.schema.json#/$defs/evidence" + "allOf": [ + { "$ref": "common.schema.json#/$defs/evidence" }, + { + "properties": { + "source": { + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "repo_detected", + "telemetry", + "runtime_ledger", + "synthetic_fixture", + "legacy_scenario" + ] + } + } + } + } + } + ] }, "unit_price": { - "$ref": "common.schema.json#/$defs/evidence" + "allOf": [ + { "$ref": "common.schema.json#/$defs/evidence" }, + { + "properties": { + "source": { + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "repo_detected", + "invoice", + "provider_catalog", + "synthetic_fixture", + "legacy_scenario" + ] + } + } + } + } + } + ] }, "total_cost": { - "$ref": "common.schema.json#/$defs/evidence" + "allOf": [ + { "$ref": "common.schema.json#/$defs/evidence" }, + { + "properties": { + "source": { + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "invoice", + "profitctl_derived", + "synthetic_fixture", + "legacy_scenario" + ] + } + } + } + } + } + ] } } } From 7e00ae8798c794adedd54e84c695420653fccc9e Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 11:57:13 -0700 Subject: [PATCH 10/12] fix: isolate mappings and evidence semantics --- pkg/contracts/cost/v1/legacy.go | 18 ++++++++++----- pkg/contracts/cost/v1/legacy_test.go | 2 ++ pkg/contracts/cost/v1/validate.go | 6 +++++ pkg/contracts/cost/v1/validate_test.go | 16 +++++++++++++ schemas/cost-contract/v1/common.schema.json | 25 ++++++++++++++++++++- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/pkg/contracts/cost/v1/legacy.go b/pkg/contracts/cost/v1/legacy.go index a232f05..be71511 100644 --- a/pkg/contracts/cost/v1/legacy.go +++ b/pkg/contracts/cost/v1/legacy.go @@ -116,11 +116,11 @@ func legacyDistribution(cost types.VariableCost) *Distribution { } distribution := &Distribution{ Type: DistributionType(cost.Distribution), - Mean: cost.Mean, - StdDev: cost.StdDev, - Min: cost.Min, - Max: cost.Max, - Rate: cost.Rate, + Mean: cloneFloat(cost.Mean), + StdDev: cloneFloat(cost.StdDev), + Min: cloneFloat(cost.Min), + Max: cloneFloat(cost.Max), + Rate: cloneFloat(cost.Rate), } if cost.Distribution == types.DistNormal || cost.Distribution == types.DistUniform { floor := 0.0 @@ -129,6 +129,14 @@ func legacyDistribution(cost types.VariableCost) *Distribution { return distribution } +func cloneFloat(value *float64) *float64 { + if value == nil { + return nil + } + copy := *value + return © +} + func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { evidence := Evidence{ Kind: EvidencePredicted, diff --git a/pkg/contracts/cost/v1/legacy_test.go b/pkg/contracts/cost/v1/legacy_test.go index 7a9dd86..c7ab53a 100644 --- a/pkg/contracts/cost/v1/legacy_test.go +++ b/pkg/contracts/cost/v1/legacy_test.go @@ -96,6 +96,8 @@ func TestLegacyMappingPreservesNormalNonNegativeFloor(t *testing.T) { require.NoError(t, err) require.NotNil(t, drivers[0].Distribution.Floor) require.Zero(t, *drivers[0].Distribution.Floor) + *drivers[0].Distribution.Mean = 99 + require.Equal(t, 1.0, mean) } func TestLegacyMappingPreservesUniformNonNegativeFloor(t *testing.T) { diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go index 4d71968..0c0731a 100644 --- a/pkg/contracts/cost/v1/validate.go +++ b/pkg/contracts/cost/v1/validate.go @@ -284,6 +284,9 @@ func (e Evidence) Validate() error { if e.Source.Type == SourceRepoDetected && e.Confidence == ConfidenceHigh { return errors.New("repo_detected evidence cannot claim high confidence") } + if e.Source.Type == SourceSyntheticFixture && e.Confidence == ConfidenceHigh { + return errors.New("synthetic_fixture evidence cannot claim high confidence") + } if e.Source.Type == SourceProfitCtlDerived && e.Measurement != MeasurementDerived { return errors.New("profitctl_derived evidence must use derived measurement") } @@ -303,6 +306,9 @@ func (e Evidence) Validate() error { if e.Kind == EvidenceUserSupplied && e.Source.Type != SourceUserSupplied { return errors.New("user_supplied evidence requires user_supplied source") } + if e.Kind == EvidenceUserSupplied && e.Measurement != MeasurementDeclared { + return errors.New("user_supplied evidence must be declared") + } if e.Kind == EvidenceObserved && e.Measurement != MeasurementMeasured && e.Measurement != MeasurementSynthetic { return errors.New("observed evidence must be measured or synthetic") } diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go index 7aa4ecc..46bf9fc 100644 --- a/pkg/contracts/cost/v1/validate_test.go +++ b/pkg/contracts/cost/v1/validate_test.go @@ -224,6 +224,22 @@ func TestRepoDetectedEvidenceCannotClaimHighConfidence(t *testing.T) { require.ErrorContains(t, evidence.Validate(), "repo_detected evidence cannot claim high confidence") } +func TestSyntheticFixtureEvidenceCannotClaimHighConfidence(t *testing.T) { + evidence := validEvidence() + evidence.Kind = v1.EvidenceObserved + evidence.Measurement = v1.MeasurementSynthetic + evidence.Source.Type = v1.SourceSyntheticFixture + evidence.Confidence = v1.ConfidenceHigh + require.ErrorContains(t, evidence.Validate(), "synthetic_fixture evidence cannot claim high confidence") +} + +func TestUserSuppliedEvidenceMustBeDeclared(t *testing.T) { + evidence := validEvidence() + evidence.Kind = v1.EvidenceUserSupplied + evidence.Measurement = v1.MeasurementDerived + require.ErrorContains(t, evidence.Validate(), "must be declared") +} + func TestObservationRejectsDuplicateDriverIDs(t *testing.T) { var fixture struct { Observation v1.CostObservation `json:"observation"` diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json index 634c953..653eea0 100644 --- a/schemas/cost-contract/v1/common.schema.json +++ b/schemas/cost-contract/v1/common.schema.json @@ -128,7 +128,11 @@ ] }, "artifact_identity": { "type": "string", "pattern": ".*\\S.*" }, - "url": { "type": "string", "format": "uri" }, + "url": { + "type": "string", + "format": "uri", + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*://[^/\\s]+" + }, "captured_at": { "anyOf": [ { "type": "string", "format": "date" }, @@ -169,6 +173,24 @@ } } }, + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "synthetic_fixture" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "confidence": { "enum": ["low", "medium"] } + } + } + }, { "if": { "properties": { @@ -221,6 +243,7 @@ }, "then": { "properties": { + "measurement": { "const": "declared" }, "source": { "properties": { "type": { "const": "user_supplied" } From d1cbf0ee3d727bd84dbbd1881fdbc60ead0fe66f Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 12:12:40 -0700 Subject: [PATCH 11/12] fix: tighten legacy evidence validation --- pkg/contracts/cost/v1/validate.go | 3 +++ pkg/contracts/cost/v1/validate_test.go | 7 +++++++ schemas/cost-contract/v1/common.schema.json | 20 +++++++++++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go index 0c0731a..f2954bf 100644 --- a/pkg/contracts/cost/v1/validate.go +++ b/pkg/contracts/cost/v1/validate.go @@ -287,6 +287,9 @@ func (e Evidence) Validate() error { if e.Source.Type == SourceSyntheticFixture && e.Confidence == ConfidenceHigh { return errors.New("synthetic_fixture evidence cannot claim high confidence") } + if e.Source.Type == SourceLegacyScenario && e.Confidence == ConfidenceHigh { + return errors.New("legacy_scenario evidence cannot claim high confidence") + } if e.Source.Type == SourceProfitCtlDerived && e.Measurement != MeasurementDerived { return errors.New("profitctl_derived evidence must use derived measurement") } diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go index 46bf9fc..6fddab5 100644 --- a/pkg/contracts/cost/v1/validate_test.go +++ b/pkg/contracts/cost/v1/validate_test.go @@ -233,6 +233,13 @@ func TestSyntheticFixtureEvidenceCannotClaimHighConfidence(t *testing.T) { require.ErrorContains(t, evidence.Validate(), "synthetic_fixture evidence cannot claim high confidence") } +func TestLegacyScenarioEvidenceCannotClaimHighConfidence(t *testing.T) { + evidence := validEvidence() + evidence.Source.Type = v1.SourceLegacyScenario + evidence.Confidence = v1.ConfidenceHigh + require.ErrorContains(t, evidence.Validate(), "legacy_scenario evidence cannot claim high confidence") +} + func TestUserSuppliedEvidenceMustBeDeclared(t *testing.T) { evidence := validEvidence() evidence.Kind = v1.EvidenceUserSupplied diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json index 653eea0..6c741cb 100644 --- a/schemas/cost-contract/v1/common.schema.json +++ b/schemas/cost-contract/v1/common.schema.json @@ -131,7 +131,7 @@ "url": { "type": "string", "format": "uri", - "pattern": "^[A-Za-z][A-Za-z0-9+.-]*://[^/\\s]+" + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*://[^/?#\\s]+" }, "captured_at": { "anyOf": [ @@ -173,6 +173,24 @@ } } }, + { + "if": { + "properties": { + "source": { + "properties": { + "type": { "const": "legacy_scenario" } + }, + "required": ["type"] + } + }, + "required": ["source"] + }, + "then": { + "properties": { + "confidence": { "enum": ["low", "medium"] } + } + } + }, { "if": { "properties": { From 04d2ee3babe6d1ac10f9827f41a0c8cb15a3fce7 Mon Sep 17 00:00:00 2001 From: hudsonaikins-crown Date: Sat, 1 Aug 2026 12:30:41 -0700 Subject: [PATCH 12/12] fix: split driver claim evidence --- docs/contracts/cost-contract-v1.md | 4 +- pkg/contracts/cost/v1/legacy.go | 18 ++++++- pkg/contracts/cost/v1/legacy_test.go | 18 +++---- pkg/contracts/cost/v1/types.go | 27 ++++++---- pkg/contracts/cost/v1/validate.go | 23 ++++++-- pkg/contracts/cost/v1/validate_test.go | 43 ++++++++++++--- schemas/cost-contract/v1/common.schema.json | 2 +- .../cost-contract/v1/cost-driver.schema.json | 54 ++++++++++++++++++- .../v1/upstash_idle_polling.json | 29 +++++++--- 9 files changed, 173 insertions(+), 45 deletions(-) diff --git a/docs/contracts/cost-contract-v1.md b/docs/contracts/cost-contract-v1.md index e03d140..5bbed7b 100644 --- a/docs/contracts/cost-contract-v1.md +++ b/docs/contracts/cost-contract-v1.md @@ -34,8 +34,8 @@ Every driver requires: - explicit RFC3339 start and end; - price amount, ISO 4217 currency, and price basis; - workload dimension; -- evidence class, measurement status, source identity, capture time, - confidence, and rationale. +- separate quantity and unit-price evidence, each with evidence class, + measurement status, source identity, capture time, confidence, and rationale. `variable` and `cadence` drivers also require a `per` scale basis. A variable driver cannot omit whether it scales per `user`, `paid_user`, or another diff --git a/pkg/contracts/cost/v1/legacy.go b/pkg/contracts/cost/v1/legacy.go index be71511..200d9e4 100644 --- a/pkg/contracts/cost/v1/legacy.go +++ b/pkg/contracts/cost/v1/legacy.go @@ -59,7 +59,7 @@ func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapp }, Window: mapping.Window, Dimensions: Dimensions{Workload: cost.Name}, - Evidence: legacyEvidence(cost.Source, mapping), + Evidence: legacyDriverEvidence(cost.Source, mapping), } if err := driver.Validate(); err != nil { return nil, fmt.Errorf("fixed cost %q: %w", cost.Name, err) @@ -94,7 +94,7 @@ func MapLegacyCosts(fixed []types.FixedCost, variable []types.VariableCost, mapp }, Window: mapping.Window, Dimensions: Dimensions{Workload: cost.Name}, - Evidence: legacyEvidence(cost.Source, mapping), + Evidence: legacyDriverEvidence(cost.Source, mapping), Distribution: legacyDistribution(cost), } if err := driver.Validate(); err != nil { @@ -137,6 +137,20 @@ func cloneFloat(value *float64) *float64 { return © } +func legacyDriverEvidence(source *types.CostSource, mapping LegacyMapping) DriverEvidence { + quantity := legacyEvidence(source, mapping) + unitPrice := legacyEvidence(source, mapping) + if err := validateClaimAuthority("quantity", quantity.Source.Type); err != nil { + quantity = legacyEvidence(nil, mapping) + quantity.ConfidenceRationale = "Legacy source was not authoritative for quantity; mapped as an unmeasured scenario assumption." + } + if err := validateClaimAuthority("unit_price", unitPrice.Source.Type); err != nil { + unitPrice = legacyEvidence(nil, mapping) + unitPrice.ConfidenceRationale = "Legacy source was not authoritative for unit price; mapped as an unmeasured scenario assumption." + } + return DriverEvidence{Quantity: quantity, UnitPrice: unitPrice} +} + func legacyEvidence(source *types.CostSource, mapping LegacyMapping) Evidence { evidence := Evidence{ Kind: EvidencePredicted, diff --git a/pkg/contracts/cost/v1/legacy_test.go b/pkg/contracts/cost/v1/legacy_test.go index c7ab53a..c47ebed 100644 --- a/pkg/contracts/cost/v1/legacy_test.go +++ b/pkg/contracts/cost/v1/legacy_test.go @@ -164,8 +164,8 @@ func TestLegacyMappingCapsHighConfidenceTemplate(t *testing.T) { }, }) require.NoError(t, err) - require.Equal(t, v1.SourceTemplate, drivers[0].Evidence.Source.Type) - require.Equal(t, v1.ConfidenceMedium, drivers[0].Evidence.Confidence) + require.Equal(t, v1.SourceTemplate, drivers[0].Evidence.Quantity.Source.Type) + require.Equal(t, v1.ConfidenceMedium, drivers[0].Evidence.Quantity.Confidence) } func TestLegacyMappingCapsHighConfidenceRepoDetected(t *testing.T) { @@ -187,7 +187,7 @@ func TestLegacyMappingCapsHighConfidenceRepoDetected(t *testing.T) { }, }) require.NoError(t, err) - require.Equal(t, v1.ConfidenceMedium, drivers[0].Evidence.Confidence) + require.Equal(t, v1.ConfidenceMedium, drivers[0].Evidence.Quantity.Confidence) } func TestLegacyMappingFallsBackFromFreeFormCaptureText(t *testing.T) { @@ -210,8 +210,8 @@ func TestLegacyMappingFallsBackFromFreeFormCaptureText(t *testing.T) { }, }) require.NoError(t, err) - require.Equal(t, "2026-08-01", drivers[0].Evidence.Source.CapturedAt) - require.Contains(t, drivers[0].Evidence.ConfidenceRationale, "May 2026") + require.Equal(t, "2026-08-01", drivers[0].Evidence.Quantity.Source.CapturedAt) + require.Contains(t, drivers[0].Evidence.Quantity.ConfidenceRationale, "May 2026") } func TestLegacyMappingFailsClosedWithoutVariableUnit(t *testing.T) { @@ -297,10 +297,10 @@ func TestLegacyMappingDowngradesIncompleteProviderCatalogProvenance(t *testing.T }, }) require.NoError(t, err) - require.Equal(t, v1.SourceLegacyScenario, drivers[0].Evidence.Source.Type) - require.Equal(t, "https://example.com/pricing", drivers[0].Evidence.Source.URL) - require.Equal(t, v1.ConfidenceLow, drivers[0].Evidence.Confidence) - require.Contains(t, drivers[0].Evidence.ConfidenceRationale, "without v1 refresh policy") + require.Equal(t, v1.SourceLegacyScenario, drivers[0].Evidence.Quantity.Source.Type) + require.Equal(t, "https://example.com/pricing", drivers[0].Evidence.Quantity.Source.URL) + require.Equal(t, v1.ConfidenceLow, drivers[0].Evidence.Quantity.Confidence) + require.Contains(t, drivers[0].Evidence.Quantity.ConfidenceRationale, "without v1 refresh policy") } func TestCompatibilityFixtureIsStableJSON(t *testing.T) { diff --git a/pkg/contracts/cost/v1/types.go b/pkg/contracts/cost/v1/types.go index cce8b94..373e484 100644 --- a/pkg/contracts/cost/v1/types.go +++ b/pkg/contracts/cost/v1/types.go @@ -124,17 +124,22 @@ type Evidence struct { } type CostDriver struct { - SchemaVersion string `json:"schema_version" yaml:"schema_version"` - ID string `json:"id" yaml:"id"` - Name string `json:"name" yaml:"name"` - Kind DriverKind `json:"kind" yaml:"kind"` - Quantity Quantity `json:"quantity" yaml:"quantity"` - Per *Quantity `json:"per,omitempty" yaml:"per,omitempty"` - UnitPrice UnitPrice `json:"unit_price" yaml:"unit_price"` - Window TimeWindow `json:"window" yaml:"window"` - Dimensions Dimensions `json:"dimensions" yaml:"dimensions"` - Evidence Evidence `json:"evidence" yaml:"evidence"` - Distribution *Distribution `json:"distribution,omitempty" yaml:"distribution,omitempty"` + SchemaVersion string `json:"schema_version" yaml:"schema_version"` + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + Kind DriverKind `json:"kind" yaml:"kind"` + Quantity Quantity `json:"quantity" yaml:"quantity"` + Per *Quantity `json:"per,omitempty" yaml:"per,omitempty"` + UnitPrice UnitPrice `json:"unit_price" yaml:"unit_price"` + Window TimeWindow `json:"window" yaml:"window"` + Dimensions Dimensions `json:"dimensions" yaml:"dimensions"` + Evidence DriverEvidence `json:"evidence" yaml:"evidence"` + Distribution *Distribution `json:"distribution,omitempty" yaml:"distribution,omitempty"` +} + +type DriverEvidence struct { + Quantity Evidence `json:"quantity" yaml:"quantity"` + UnitPrice Evidence `json:"unit_price" yaml:"unit_price"` } type ClaimEvidence struct { diff --git a/pkg/contracts/cost/v1/validate.go b/pkg/contracts/cost/v1/validate.go index f2954bf..bf66e33 100644 --- a/pkg/contracts/cost/v1/validate.go +++ b/pkg/contracts/cost/v1/validate.go @@ -61,11 +61,23 @@ func (d CostDriver) Validate() error { if err := d.Dimensions.Validate(); err != nil { return fmt.Errorf("dimensions: %w", err) } - if err := d.Evidence.Validate(); err != nil { - return fmt.Errorf("evidence: %w", err) + driverClaims := []struct { + name string + evidence Evidence + }{ + {"quantity", d.Evidence.Quantity}, + {"unit_price", d.Evidence.UnitPrice}, } - if err := validateEvidenceFreshForWindow(d.Evidence, d.Window); err != nil { - return fmt.Errorf("evidence: %w", err) + for _, claim := range driverClaims { + if err := claim.evidence.Validate(); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } + if err := validateClaimAuthority(claim.name, claim.evidence.Source.Type); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } + if err := validateEvidenceFreshForWindow(claim.evidence, d.Window); err != nil { + return fmt.Errorf("evidence.%s: %w", claim.name, err) + } } if d.Distribution != nil { if d.Kind != DriverVariable { @@ -172,6 +184,9 @@ func (o CostObservation) Validate() error { } if o.Evidence.TotalCost.Measurement == MeasurementDerived { expected := o.Quantity.Value * o.UnitPrice.Amount.Amount / o.UnitPrice.Per.Value + if math.IsNaN(expected) || math.IsInf(expected, 0) { + return errors.New("derived total_cost calculation must remain finite") + } tolerance := math.Max(1e-9, math.Abs(expected)*1e-9) if math.Abs(o.TotalCost.Amount-expected) > tolerance { return fmt.Errorf("derived total_cost amount %.12g must equal quantity times unit price %.12g", o.TotalCost.Amount, expected) diff --git a/pkg/contracts/cost/v1/validate_test.go b/pkg/contracts/cost/v1/validate_test.go index 6fddab5..24265d4 100644 --- a/pkg/contracts/cost/v1/validate_test.go +++ b/pkg/contracts/cost/v1/validate_test.go @@ -179,6 +179,19 @@ func TestProfitCtlDerivedEvidenceRequiresDerivedMeasurement(t *testing.T) { require.ErrorContains(t, fixture.Observation.Validate(), "must use derived measurement") } +func TestObservationRejectsOverflowingDerivedTotal(t *testing.T) { + var fixture struct { + Observation v1.CostObservation `json:"observation"` + } + decodeFixture(t, "upstash_idle_polling.json", &fixture) + fixture.Observation.Quantity.Value = 1e308 + fixture.Observation.UnitPrice.Amount.Amount = 1e308 + fixture.Observation.TotalCost.Amount = 0 + fixture.Observation.Evidence.TotalCost.Source.Type = v1.SourceProfitCtlDerived + fixture.Observation.Evidence.TotalCost.Source.ArtifactIdentity = "profitctl://derived/overflow" + require.ErrorContains(t, fixture.Observation.Validate(), "must remain finite") +} + func TestProviderCatalogRequiresStalePolicyAndCapsConfidence(t *testing.T) { evidence := validEvidence() evidence.Source.Type = v1.SourceProviderCatalog @@ -199,17 +212,30 @@ func TestProviderCatalogRequiresStalePolicyAndCapsConfidence(t *testing.T) { func TestDriverRejectsCatalogStaleBeforeWindow(t *testing.T) { driver := validDriver() - driver.Evidence.Source.Type = v1.SourceProviderCatalog - driver.Evidence.Source.RefreshOwner = "catalog-maintainer" - driver.Evidence.Source.RefreshCadence = "30d" - driver.Evidence.Source.CapturedAt = "2026-07-01" - driver.Evidence.Source.StaleAfter = "2026-07-31" + driver.Evidence.UnitPrice.Source.Type = v1.SourceProviderCatalog + driver.Evidence.UnitPrice.Source.RefreshOwner = "catalog-maintainer" + driver.Evidence.UnitPrice.Source.RefreshCadence = "30d" + driver.Evidence.UnitPrice.Source.CapturedAt = "2026-07-01" + driver.Evidence.UnitPrice.Source.StaleAfter = "2026-07-31" require.ErrorContains(t, driver.Validate(), "stale before window start") - driver.Evidence.Source.StaleAfter = "2026-08-15" + driver.Evidence.UnitPrice.Source.StaleAfter = "2026-08-15" require.NoError(t, driver.Validate()) } +func TestDriverEnforcesClaimSpecificAuthority(t *testing.T) { + driver := validDriver() + driver.Evidence.UnitPrice.Source.Type = v1.SourceTelemetry + require.ErrorContains(t, driver.Validate(), "not authoritative for unit_price") + + driver = validDriver() + driver.Evidence.Quantity.Source.Type = v1.SourceProviderCatalog + driver.Evidence.Quantity.Source.RefreshOwner = "catalog-maintainer" + driver.Evidence.Quantity.Source.RefreshCadence = "30d" + driver.Evidence.Quantity.Source.StaleAfter = "2026-08-15" + require.ErrorContains(t, driver.Validate(), "not authoritative for quantity") +} + func TestTemplateEvidenceCannotClaimHighConfidence(t *testing.T) { evidence := validEvidence() evidence.Source.Type = v1.SourceTemplate @@ -308,7 +334,10 @@ func validDriver() v1.CostDriver { End: "2026-09-01T00:00:00Z", }, Dimensions: v1.Dimensions{Workload: "api_requests"}, - Evidence: validEvidence(), + Evidence: v1.DriverEvidence{ + Quantity: validEvidence(), + UnitPrice: validEvidence(), + }, } } diff --git a/schemas/cost-contract/v1/common.schema.json b/schemas/cost-contract/v1/common.schema.json index 6c741cb..1ae30d9 100644 --- a/schemas/cost-contract/v1/common.schema.json +++ b/schemas/cost-contract/v1/common.schema.json @@ -131,7 +131,7 @@ "url": { "type": "string", "format": "uri", - "pattern": "^[A-Za-z][A-Za-z0-9+.-]*://[^/?#\\s]+" + "pattern": "^[A-Za-z][A-Za-z0-9+.-]*://[^/?#\\s]+[^\\s]*$" }, "captured_at": { "anyOf": [ diff --git a/schemas/cost-contract/v1/cost-driver.schema.json b/schemas/cost-contract/v1/cost-driver.schema.json index fd09e68..0469f75 100644 --- a/schemas/cost-contract/v1/cost-driver.schema.json +++ b/schemas/cost-contract/v1/cost-driver.schema.json @@ -44,7 +44,59 @@ "$ref": "common.schema.json#/$defs/dimensions" }, "evidence": { - "$ref": "common.schema.json#/$defs/evidence" + "type": "object", + "additionalProperties": false, + "required": ["quantity", "unit_price"], + "properties": { + "quantity": { + "allOf": [ + { "$ref": "common.schema.json#/$defs/evidence" }, + { + "properties": { + "source": { + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "repo_detected", + "telemetry", + "runtime_ledger", + "synthetic_fixture", + "legacy_scenario" + ] + } + } + } + } + } + ] + }, + "unit_price": { + "allOf": [ + { "$ref": "common.schema.json#/$defs/evidence" }, + { + "properties": { + "source": { + "properties": { + "type": { + "enum": [ + "template", + "user_supplied", + "repo_detected", + "invoice", + "provider_catalog", + "synthetic_fixture", + "legacy_scenario" + ] + } + } + } + } + } + ] + } + } }, "distribution": { "$ref": "common.schema.json#/$defs/distribution" diff --git a/test/fixtures/cost_contract/v1/upstash_idle_polling.json b/test/fixtures/cost_contract/v1/upstash_idle_polling.json index 62d562b..d3d63af 100644 --- a/test/fixtures/cost_contract/v1/upstash_idle_polling.json +++ b/test/fixtures/cost_contract/v1/upstash_idle_polling.json @@ -36,15 +36,28 @@ "workload": "idle_worker_polling" }, "evidence": { - "kind": "predicted", - "measurement": "declared", - "source": { - "type": "user_supplied", - "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json", - "captured_at": "2026-08-01" + "quantity": { + "kind": "predicted", + "measurement": "declared", + "source": { + "type": "user_supplied", + "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json", + "captured_at": "2026-08-01" + }, + "confidence": "low", + "confidence_rationale": "Historical command quantity supplied for a compatibility fixture; no runtime measurement claim." }, - "confidence": "low", - "confidence_rationale": "Historical quantity and synthetic unit rate; no live provider or billing claim." + "unit_price": { + "kind": "predicted", + "measurement": "declared", + "source": { + "type": "user_supplied", + "artifact_identity": "test/fixtures/cost_contract/v1/upstash_idle_polling.json", + "captured_at": "2026-08-01" + }, + "confidence": "low", + "confidence_rationale": "Synthetic user-supplied unit rate; no current provider price or billing claim." + } } } ],