Skip to content

sec(azure): purchase spend caps and recorded Azure costs use lookback-window amounts (cap can be exceeded; stale rows after the fix) #612

Closed

Description

@cristim

Follow-up to cloud-commitments-go#267 (Azure reservation recommendation costs are lookback-window amounts treated as term totals). Found by a read-only audit of every consumer of those fields on 2026-10-06. Documented in code unless marked (I) = inferred.

Exposure today: a spend cap can be exceeded on Azure purchases

recTotalCommitment (internal/api/handler_purchases.go:2608) = UpfrontCost + MonthlyCost * Term * 12. It feeds MaxPurchaseAmount (purchaseConstraintSets, :2574) via priceFromStored (internal/api/purchase_pricing.go:148).

With the wrong Azure units:

  • an upfront variant counts as one lookback window, about 1/52 of the real 1-year cost (1/156 of a 3-year one), if the lookback is the documented default of 7 days (I);
  • a monthly variant counts as about 2x the window.

A user with a purchase cap can therefore buy Azure reservations far above it. The four-eyes approval is not amount-based (internal/purchase/approvals.go) and does not compensate.

Wrong numbers already recorded for executed Azure purchases

No automatic repair exists. purchase_history.upfront_cost (written from result.Cost at internal/purchase/execution.go:945), the execution's total_upfront_cost, approval email amounts, and savings in history/dashboard totals (handler_dashboard.go:713 YTD, handler_history.go:1121) all carry window-sized amounts. Monthly-variant purchases stored a non-zero upfront. There is no stored term total to backfill from.

What must accompany the library fix (go#267) when it is re-pinned

  1. Replace stale rows. UpsertRecommendations (internal/config/store_postgres_recommendations.go:~65-110) replaces old rows only after a successful collection for that (provider, account), and nothing forces a collection on deploy (cron recommendation_schedule or the 6h cache TTL, scheduler.go:101). Old-unit rows can be shown and purchased in that window, and rows of a failed subscription linger indefinitely. Either a migration that invalidates the Azure Consumption-API rows (precedent: migration 000049) or a forced collection on deploy.
  2. Decide what happens to pending Azure purchase_executions created before the re-pin: they carry copies of the old values (recommendations JSONB, total_upfront_cost; handler_purchases.go:2143, retry copy ~2100) and would execute with the old CommitmentCost (execution.go:1080). Refuse approval, or reprice from the store.
  3. A release note: caps will now see true totals, so purchases that passed before will be denied.
  4. Decide on a flag/repair for historical rows already recorded wrong (history, dashboards).
  5. CLI: CSVs exported before the re-pin carry old units and, if replayed into purchase, bring the old values back (cmd/multi_service_csv.go:291-337; low). MCP: nonZeroCostPtr(CommitmentCost) turns a monthly variant's 0 into "unknown" instead of "no upfront" (display only, mcp/tools/purchase.go:612).

Platform regression tests to add

  • stored upfront variant: UpfrontCost = term total, MonthlyCost 0; monthly variant: UpfrontCost 0, MonthlyCost > 0; recTotalCommitment equal for both;
  • buildRecommendationsResponse total upfront sums only upfront variants;
  • priceFromStored scales all fields at count 2;
  • a cap between the old and the new total now denies;
  • an upsert replaces an old-units row on the same natural key.

Unchanged

ScaleRecommendationCosts is linear and keeps nil/0 intact; idempotency keys and recIdentityKey hash no cost fields; effectiveSavingsPct is a ratio of two fields scaled by the same factor; the frontend already treats upfront_cost as a term total and savings/on-demand as monthly.

Activity

  1. cristim commented on Oct 7, 2026

    @cristim
    MemberAuthor

    Claimed by worker 2 of 4 (shard rank 2). The re-pin this issue gates on is in flight as PR #652 (providers/azure @ e24345ce68f8). Starting the accompanying platform work now: stale-row invalidation migration, pending-execution policy, and the listed regression tests. Plan: (1) migration invalidating pre-fix Azure recommendation rows per the 000049 precedent, (2) refuse-or-reprice decision for pending Azure purchase_executions carrying old units, (3) regression tests from the issue checklist. Will post the pending-execution decision here before implementing it.

  2. cristim commented on Oct 7, 2026

    @cristim
    MemberAuthor

    Design decisions before implementation (worker 2):

    1. Stale rows → invalidation migration (000106), 000049 shape. All Azure rows present at migration time predate the fixed library (the re-pin lands via #475), so: UPDATE recommendations SET payload = jsonb_set(jsonb_set(payload, '{upfront_cost}', 'null'), '{monthly_cost}', 'null') WHERE payload->>'provider' = 'azure'. Nulled costs make recTotalCommitment 0 → requireNonZeroCommitment and priceFromStored refuse, so old-unit rows become unpurchasable but stay visible with a stale banner until the next collection replaces them (same trade-off 000046/000049 accepted; no forced collection because Azure collect exceeds the Lambda 60s API timeout).

    2. Pending Azure purchase_executions → refuse approval on amount drift (not silent reprice). Approval reads the persisted submit-time copies; the migration only touches the recommendations table, so the guard re-resolves each Azure rec from the store at approve time and refuses (409) when the re-resolved total commitment differs from the persisted approved amount. Silent repricing would let an approver sign off on window-sized amounts and execute term-sized ones — four-eyes demands the amounts match what was approved. Old-unit executions therefore die with a clear "re-collected after the Azure units fix; resubmit" 409, whether the stored row is invalidated (total 0) or re-collected (true total).

    3. Release note: PR body — caps now see true term totals, purchases that passed before will be denied.

    4. Historical rows: no repair possible (no stored term total to backfill from; documented in the issue). Marking as won't-fix-by-design in the PR; dashboards/history keep window-sized amounts for pre-fix Azure executions.

    5. CLI/MCP items: sibling repos — filing follow-up issues there (cloud-commitments-cli CSV replay, cloud-commitments-mcp nonZeroCostPtr).

    6. Regression tests per the issue checklist: recTotalCommitment variant parity, buildRecommendationsResponse upfront-only sum, priceFromStored count-2 scaling, cap-between-old-and-new denies, upsert replaces old-units row on natural key, migration up/down test per the 000060 precedent.

  3. cristim commented on Oct 7, 2026

    @cristim
    MemberAuthor

    Draft PR #655 implements stale-price invalidation and refusal: #655

    Two corrections to the earlier design comment:

    • Migration 106 invalidates all Azure rows present at migration time, including any already-correct rows. Deploy it with the fixed collector pin owned by fix(azure): roll out reservation fixes and preserve exchange scopes #475 and quiesce old collector instances first; an old collector can otherwise repopulate wrong units. Only a successful collection replaces those rows. There is no guaranteed cache-TTL recovery or stale-banner behavior.
    • The current CLI CSV parser ignores upfront/monthly price columns, and its purchase-client factory is AWS-only. The stale Azure CSV replay defect was not reproduced, so no CLI defect issue is being filed. The separate MCP known-zero upfront preview report is prepared locally, with publication approval pending.

    Historical pre-fix execution amounts remain recorded as originally stored. No stored term total supports automatic backfill. This parent issue stays open to document that decision and the rollout dependency; the draft PR uses Refs #612.

    Local Go 1.26.6 build, full race-short suite, fail-first guard checks and PostgreSQL migration/upsert checks passed. These are mock/fixture checks. The required real Azure scenario and exact claude-opus-5-5 reviewed-SHA verdict remain pending; the reviewer runtime reported a weekly limit resetting October 11 at noon Europe/Berlin. No purchase, deployment or merge has been performed.

  4. cristim commented on Oct 8, 2026

    @cristim
    MemberAuthor

    PR #655 merged normally as f2d60df86209eea85d81e9091c20832bd670a65f, after #475 integrated the corrected Azure provider. Fresh CI and independent adversarial review covered exact head 6464a133e3bddd9c6d520e775fda966dbcae60e7; the actual squash tree matched the independently reviewed combined tree.

    Verified locally: the real approval path with native PostgreSQL migration and pricing persistence refuses identical old positive prices when migration 106 is missing, dirty or failed, and refuses invalidated prices until successful recollection. The same fixture on pre-fix production source dispatched one mocked purchase and failed; fixed source passed. Correct recollection permits exactly one mocked provider purchase. Affected config/purchase/API race tests and fresh GitHub CI passed. Purchases and execution persistence were mocked; no real cloud purchase was made.

    Existing normal merge-triggered CI/deployment workflows are running; deployment success is not yet claimed. Old collectors must be quiesced before migration 106 rollout. Historical execution amounts remain as recorded because term prices were not stored, so #612 remains open for that previously documented limitation. MCP preview tracking remains LeanerCloud/cloud-commitments-mcp#44.

  5. cristim commented on Oct 8, 2026

    @cristim
    MemberAuthor

    Re-triaged to priority/p2 + severity/medium (fleet lead approved): the exposure this issue was filed for is closed.

    Remaining item 4 (flagging historical purchase amounts recorded in lookback-window units) is split into the new issue below and waits on two owner decisions. Closing this issue as fixed by #655.

    New issue: #669

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions