Skip to content

fix(purchase): refuse unverified ramp sizing and adoption - #905

Merged
cristim merged 4 commits into
mainfrom
fix/631-retention-ledger-runtime
Oct 17, 2026
Merged

cristim merged 4 commits into
mainfrom
fix/631-retention-ledger-runtime

Conversation

@cristim

@cristim cristim commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

What and why

Runtime half of ramp-ledger retention. Ramps now get fresh lineage at creation, frozen atomically. Authorized quantities are preserved, but new ramp sizing or notification adoption is refused when the history cannot be verified. This stops the system from acting on a ledger that retention could not protect.

Stack position

2 of 2. Base: fix/631-retention-ledger-db (the migration PR). Retarget to main after that PR merges.

Verification (local, macOS)

  • go build ./... and go test -race ./internal/config/... ./internal/purchase/... pass.
  • New integration tests (plan_step_retention_integration_test.go, store_postgres_ramp_retention_integration_test.go) run against a real local Postgres with migrations applied.
  • CI results are tracked on this PR.

Deploy note

Retention needs an old-Lambda drain window: roll out the migration first and let Lambdas from before this change drain before relying on the refusal behaviour.

Refs #631

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Ramp-based purchase plans now check schedule history before sizing or attaching recommendations. Missing or untrusted history prevents processing and notification.
    • Changes to a plan’s ramp schedule or service identity invalidate its baseline, preventing outdated history from being used.
    • Failed purchase steps now handle recommendation visibility and suppression cleanup more consistently.
    • Newly created and eligible never-started plans retain ramp history appropriately, while legacy plans with uncertain history remain blocked from sizing.

@cristim cristim added urgency/this-sprint Within the current sprint triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm impact/many Affects most users effort/xl Multi-week / refactor type/feat New capability labels Oct 15, 2026
@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: ddd317b2-a315-494a-888c-34c5003d9961
📥 Commits

Reviewing files that changed from the base of the PR and between 00eeaeb and 8c0123d.

📒 Files selected for processing: 6
  • internal/config/store_postgres_ramp_baseline.go
  • internal/database/postgres/migrations/000127_never_started_legacy_ramps_to_new.down.sql
  • internal/database/postgres/migrations/000127_never_started_legacy_ramps_to_new.up.sql
  • internal/purchase/plan_step_cumulative_integration_test.go
  • internal/purchase/plan_step_ledger_migration_integration_test.go
  • internal/purchase/plan_step_retention_integration_test.go
🚧 Files skipped from review as they are similar to previous changes: 1
  • internal/config/store_postgres_ramp_baseline.go

Included review availability: This review used your included allowance. 0 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

📝 Walkthrough

The change initializes ramp ledger state, validates ledger trust during baseline reads and writes, and blocks cumulative attachment when history is untrusted. It also updates failed-step status handling and performs execution saving and suppression deletion in one transaction.

Changes: Ramp Ledger and Cumulative Attachment

Layer / File(s) Summary
Ledger initialization and migration
internal/config/store_postgres_plan_writes.go, internal/database/postgres/migrations/000127_*, internal/config/store_postgres_ramp_retention_integration_test.go, internal/config/store_postgres_retention_integration_test.go, internal/purchase/plan_step_ledger_migration_integration_test.go
New plans receive ledger state new. The migration promotes qualifying never-started legacy plans. Integration tests check migration outcomes and ledger-state retention after edits.
Baseline trust and retention
internal/config/store_postgres_ramp_baseline.go, internal/config/store_postgres_ramp_baseline_integration_test.go, internal/config/store_postgres_ramp_retention_integration_test.go, internal/config/store_postgres_ramp_target_met_integration_test.go
Baseline reads and writes apply ledger-state and schedule-position checks. Tests cover stale baselines, retention across plan edits, and baseline-write conditions.
Cumulative attachment validation
internal/purchase/plan_step_attach.go, internal/purchase/plan_step_*integration_test.go
Attachment checks baseline trust and requires baseline freezing to write a row. Tests cover retained quantities, untrusted history, and purchase recommendation counts.
Failed-step state and suppressions
internal/purchase/notifications.go, internal/purchase/plan_step_retention_integration_test.go, known_issues/36_plan_step_fail_cas_not_atomic.md
Failed-step handling uses recommendation presence when pinning status and saves the execution while deleting suppressions in one transaction. Tests cover an approval race, and a known-issue report documents the transaction boundary.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant PlanStepAttach
  participant GetPlanRampBaselineTx
  participant PurchasePlans
  participant SetPlanRampBaselineTx
  PlanStepAttach->>GetPlanRampBaselineTx: Read baseline and ledger state
  GetPlanRampBaselineTx->>PurchasePlans: Query baseline and ramp state
  PurchasePlans-->>GetPlanRampBaselineTx: Return baseline and ramp state
  GetPlanRampBaselineTx-->>PlanStepAttach: Return baseline or ErrRampLedgerUntrusted
  PlanStepAttach->>SetPlanRampBaselineTx: Freeze resolved baseline
  SetPlanRampBaselineTx->>PurchasePlans: Write baseline and promote eligible ledger
Loading
Merge Risk: ⚪ Minimal · up to 8c012

No concrete issue in the supplied evidence prevents merging after normal checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 36.84% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 19 functions across 13 files. (2 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: refusing ramp sizing and adoption when ramp history is unverified.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 36.84% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 19 functions across 13 files. (2 skipped: 2 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim cristim left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review of c3c1d17: changes requested

Scope: git diff 17a337704f38...c3c1d171e58d (base tip is the head of #904; stack consistent, #905 is +372, 3 production files, +46/-19 production lines).

Findings

  1. ACTIONABLE (owner-decision, PR body must say it): every existing multistep ramp stops permanently, with no recovery path. internal/config/store_postgres_ramp_baseline.go:76 returns ErrRampLedgerUntrusted for any legacy multistep plan, and migration 000124 makes legacy permanent (a plan is never promoted back). All plans created before the migration, plans created by an old Lambda during the drain window, and any plan whose shape is edited after its first freeze (the trigger degrades trusted to legacy, and rampBaselineStale also clears the baseline, store_postgres.go:761) land there. Failing scenario: user attaches step 1 of a new ramp, edits the service term before approving; the plan is now legacy, and step 2 is refused forever. Nothing in the code can reset it (grep: no other writer of ramp_ledger_state). The error text says "reconcile prior commitments before attaching another step", but no reconcile action exists; the only way out is delete and recreate the plan, which detaches the ledger rows (plan_id SET NULL) and the new plan starts with an empty ledger. The PR body's "Deploy note" only covers the drain window, not that in-flight ramps halt. Fix: state the halt and the recovery procedure in the PR body and error text, or add an explicit admin re-trust path as a follow-up issue before this merges to production.

  2. ACTIONABLE: the nonempty-root refusal is not recorded and not tick-quiet. plan_step_attach.go:93-95 returns the raw ErrRampLedgerUntrusted (not wrapped in errExecutionNotNotifiable), so sendPlanNotification (notifications.go:~170) logs Failed to create execution at Error on every notification tick, and the pending/notified row stays untouched with no durable reason on it (the test asserts the row stays pending/notified). The new/bare path does record a failed row with the reason (failStep), so the two paths differ. Also the check runs before the date-passed check, so an overdue untrusted root never reaches the quiet skip. Fix: wrap with errExecutionNotNotifiable (still errors.Is-able to ErrRampLedgerUntrusted) and consider stamping the reason on the row, or accept and document the log-only surfacing.

  3. NIT: plan_step_attach.go:210-212 (!wrote branch) is unreachable after GetPlanRampBaselineTx passes under the same plan lock (its predicate equals the UPDATE's WHERE). Mutation probe: replacing it with _ = wrote leaves all config and purchase retention tests green. Harmless fail-closed defence; keep or drop.

What I attacked

  • Build and vet clean. Integration (real Postgres 16 via testcontainers): go test -tags integration -run 'TestCumulativeAttach|TestRampStep|Cumulative' ./internal/purchase/ ok; -run 'RampRetention|Retention|RampBaseline|TargetMet' ./internal/config/ ok.
  • Fail-before/pass-after (mutation on production code in a scratch worktree, reverted): disabling the GetPlanRampBaselineTx trust check fails all 6 subcases of TestCumulativeAttach_UntrustedHistoryCannotSizeOrNotify and both subcases of TestCumulativeAttach_AuthorizedLegacyQuantityRemainsImmutable. Removing the nonempty-root guard at plan_step_attach.go:93 fails exactly the 2 nonempty pending/notified subcases. Surviving: !wrote branch (finding 3).
  • TOCTOU: the trust read (GetPlanRampBaselineTx) and the freeze (SetPlanRampBaselineTx) both run in the claim transaction after LockPurchasePlanTx ... FOR UPDATE (store_postgres.go:~715), and every plan writer, including old Lambdas, needs that row lock, so state, baseline and current_step cannot change between check and attach. Authorized scheduled/approved executions are intentionally not re-checked at purchase time (they consume their frozen recommendations; the immutable7 tests cover it). No race found.
  • Double/skipped purchase: a refused step never writes recommendations, suppressions, tokens or emails (asserted: zero sends, zero suppressions, zero resolver calls). The refused bare step records a failed row with NoSubmission, so no later tick creates a second root. Single-step and immediate plans skip the check (total > 1).
  • Surfacing: bare/new step is durable (failed row with reason). Nonempty root is log-only (finding 2).

Gate status

  • Independent review: this comment, at c3c1d17.
  • Findings resolved: no, 1 and 2 open.
  • CI on SHA: not applicable until retarget (base is a branch, not main). gh pr checks shows only Trivy, gosec and a rate-limited CodeRabbit run, which is not CI coverage.
  • CodeRabbit: rate limited, no findings.
  • Labels: match #631 (effort/xl, impact/many, priority/p1, severity/high, triaged, type/feat, urgency/this-sprint).
  • Local real-path evidence: PR body claims local integration runs; I reproduced them above (macOS, Postgres 16 testcontainer, not the 17 the plan mentions). Real-account verification: none, and none claimed.
  • Verdict recorded: this comment. A new commit restarts the gate.

cristim and others added 2 commits October 16, 2026 04:30
Mint fresh ramp lineage at creation and freeze it atomically.
Preserve authorized quantities while refusing new ramp sizing or
notification adoption from unverifiable histories.
Delta of 686b9f02 over #907: the planstep and config code is identical on
main. With the retention runtime (#905) a completed protected ramp has an
untrusted baseline, so the exact-count test must assert the refusal
instead of reading the baseline; without this TestEqualCountAttachThree
fails on main plus #905.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@cristim
cristim changed the base branch from fix/631-retention-ledger-db to main October 16, 2026 02:35
@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

Retargeted to main (its base #904 merged as 94a058c) and rebased. The PR's own commit is a pure rebase: git range-diff 17a33770..c3c1d171 origin/main..501dc4eb:

1:  c3c1d171 = 1:  501dc4eb fix(purchase): refuse unverified ramp sizing and adoption

Added c9c92ed (cherry-pick of the claude-cc-631 test-only commit 43354cb3). Without it, main's TestEqualCountAttachThree (from #907) fails once this PR is applied, because a completed protected ramp now has an untrusted baseline.

Local proof with -tags integration -count=1 -run TestEqualCountAttachThree ./internal/purchase/: it fails at 501dc4e (plan_step_allocation_integration_test.go:31) and passes at c9c92ed. go build, go vet, the unit tests and 108 targeted integration tests all pass at c9c92ed.

Gate status: the head moved, so the gate restarts. My review findings on c3c1d17 are still open: (1) legacy ramps halt permanently with no recovery path, which needs a fix or an explicit owner decision; (2) the untrusted refusal logs Error every tick and records no durable reason. Note that c9c92ed asserts the behaviour in finding 1.

@cristim
cristim force-pushed the fix/631-retention-ledger-runtime branch from c3c1d17 to c9c92ed Compare October 16, 2026 02:35
@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

Automated review of c9c92ed: no blocking findings and CI is green. Ready to merge.
Logic matches the migration 000124 trigger: new plans start 'new', the freeze promotes to 'trusted' atomically, and unverifiable history is refused at read and attach. I found no defects. CI is green and the new integration tests cover the key edge cases.

  • (optional) internal/config/store_postgres_ramp_baseline.go:48: The freeze UPDATE repeats the protected and step-zero conditions that the DB trigger and ramp_ledger_protected already enforce. Two copies of the rule can drift apart. A short comment pointing at the trigger would help.
  • (optional) internal/config/store_postgres_ramp_baseline.go:36: allowsSizing mixes && and || without parentheses. It is correct, but explicit grouping would be easier to read.

Posted by a read-only review sweep. It stays silent on this commit and re-reviews only after a new push.

…essions

When the retention ledger refuses to size a step whose root execution
row already carries recommendations, fail the row like the other
refusals (Error and NoSubmission recorded, later ticks skip quietly)
instead of returning the raw sentinel, which logged an error every
tick. The pinned failure now expects the row's real recommendation
state, and a won failure releases the row's suppressions in the same
transaction as the stamp, since the step provably bought nothing.

The CAS still commits before the stamp; that pre-existing gap is
recorded in known_issues/36 and #1052.

Refs #631

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Fix for review finding 2 pushed as 00eeaeb (fast-forward from c9c92ed). Finding 1 (the permanent legacy ledger state, no recovery path) is waiting on an owner decision; nothing for it is in this commit.

What changed

  • plan_step_attach.go claimFromRows: an ErrRampLedgerUntrusted refusal for a root that already carries recommendations now goes through failStep, like the other refusals in that file. Other errors still return raw. The sentinel text is unchanged.
  • notifications.go failExistingPlanStep: the pin was hard-coded to an empty root, so the compare-and-set could never fail a row that has recommendations. It now pins len(exec.Recommendations) == 0. Every existing caller passes an empty root, so their behavior is unchanged. After a won compare-and-set, the stamp (Error, NoSubmission) and DeleteSuppressionsByExecutionTx run in one transaction, since the step provably bought nothing.
  • known_issues/36, tracked in fix(purchase): fail a plan step's root row atomically with its reason and suppression release #1052: the compare-and-set still commits before the stamp, so a crash in between leaves a failed row without its reason and keeps its suppressions until they expire. This fails closed.

Review
Independent non-author review, two passes on the exact tree 28a9fb06 (= 00eeaeb^{tree}): clean. Pass 1 found one LOW, the crash gap above, which was recorded rather than fixed here.

Local verification (macOS, testcontainers Postgres 16, the repo's test image)

  • RED before the fix: the nonempty pending and notified root subtests of TestCumulativeAttach_UntrustedHistoryCannotSizeOrNotify got the raw sentinel, and the row stayed pending.
  • GREEN after the fix: the row is failed with NoSubmission and the reason, the root's suppressions are gone, and another execution's suppression is untouched. A second tick is a quiet skip with no email and no re-sizing.
  • New TestFailExistingPlanStep_LostRaceKeepsSuppressions: an approved row is not failed, and its suppressions stay.
  • Integration -tags integration -p 1 ./internal/purchase/...: ok, with 0 SKIP.
  • Race unit tests for internal/purchase and internal/config: pass.
  • CI-pinned golangci-lint v2.10.1: 0 issues. pre-commit: pass.
  • Mutation probes, all caught by a test:
    • drop the new branch
    • pin hard-coded to true
    • pin inverted
    • drop the release
    • release keyed by plan instead of execution
    • release on the lost-race path
    • release before the compare-and-set

Refs #631

@cristim cristim left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent delta review of 00eeaeb (previous: c9c92ed): changes requested (finding 1 only, owner decision pending; no new defects in the delta)

Scope: git diff c9c92ed8..00eeaeb8 (4 files, +79/-6). OLD is an ancestor of NEW (fast-forward). git merge-tree origin/main HEAD shows no conflicts.

Previous findings

  1. Permanent legacy ledger state, no recovery path: NOT RESOLVED (owner decision). Nothing in the delta touches it; the author states it awaits the owner. It stays blocking until the owner decides or the recovery path / PR-body disclosure lands.
  2. Untrusted-root refusal logged Error every tick with no durable reason: RESOLVED. plan_step_attach.go:99-103 routes ErrRampLedgerUntrusted for a nonempty root through failStep (-> claim.emptyRoot, notifications.go:248-251 -> failExistingPlanStep), which stamps failed/NoSubmission/reason. adoptableRootRow only adopts pending/notified, so the next tick is a quiet skip. Proof: the extended TestCumulativeAttach_UntrustedHistoryCannotSizeOrNotify fails on OLD production code with NEW tests (both nonempty subcases: "Target error should be in err chain") and passes on NEW.
  3. !wrote unreachable branch (NIT): unchanged, still non-blocking.

Delta attack (money path)

  • Cannot fail a step that should have run: the failed row is only reached after GetPlanRampBaselineTx returned ErrRampLedgerUntrusted under the plan row lock, the same condition that already refused to notify it. A refused step never reached an approval token/recommendation write, so failing it forgoes no purchase. The CAS (store_postgres.go:1229-1230) is execution_id = $1 AND status IN (pending, notified), so an approved/scheduled/in-flight row is untouched (lost race returns errExecutionNotNotifiable).
  • Pin change (notifications.go:301): the CAS is still keyed on one execution_id, so it cannot fail another root's step. The recommendations pin now equals what the caller read (len(exec.Recommendations) == 0); the other callers (failDueManualStep, bare path) pass empty roots, so they still pin empty. A row whose recommendations changed since the read fails the CAS and is skipped.
  • Suppression release (notifications.go:310-318): runs only after a won CAS, in one tx with the stamp, keyed by execution id (store_postgres_suppressions.go:139). The new TestFailExistingPlanStep_LostRaceKeepsSuppressions shows an approved row keeps its suppressions; the retention test shows another execution's suppression is untouched. It passes on OLD as well, so it guards the new release rather than proving the fix.
  • Residual: CAS commits before the stamp/release tx, so a crash between leaves a failed row without a reason and with suppressions until expiry. It fails closed (no second root, nothing bought). Recorded in known_issues/36_plan_step_fail_cas_not_atomic.md and #1052. NON-BLOCKING.
  • Order change: the untrusted check now precedes the date-passed skip for nonempty roots; an overdue untrusted root is failed durably instead of skipped. Intended.

Tests run (macOS, testcontainers Postgres, Docker available)

  • NEW: go build ./..., go vet ./internal/purchase/, go test -tags integration -p 1 -run 'TestCumulativeAttach|TestFailExistingPlanStep|TestEqualCountAttach|TestRampStep' ./internal/purchase/ ok; go test ./internal/purchase/ ok.
  • OLD production + NEW tests: the two nonempty subtests fail as above. Worktree restored afterwards.
  • I did not re-run the author's 7 mutation probes.

Gate status

  • Independent review: this comment at 00eeaeb; delta clean, finding 1 open (owner decision).
  • CI on 00eeaeb: run 39037632588 (CI - Build & Test) in progress at review time; pre-commit pass; no action_required runs seen. Not waited for.
  • CodeRabbit: in progress / rate limited, no findings posted on this SHA.
  • Labels: match #631 set (effort/xl, impact/many, priority/p1, severity/high, triaged, type/feat, urgency/this-sprint).
  • Local evidence: macOS integration reproduced as above; no real-account verification claimed.

Migration 000127 marks a protected multi-step ramp 'new' again when its
retention ledger is 'legacy' but it is still at step 0, has no frozen
baseline and has no surviving purchase execution rows. Such plans then
freeze a baseline at their first step like any new plan. The ledger
guard trigger is disabled only around that update, inside the
migration transaction. Mid-ramp legacy plans are left for a follow-up
change.

The untrusted-ledger refusal now says what is wrong instead of naming a
reconcile action that does not exist.

Refs #631

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Merge driver note on 8c0123d: it adds migration 000127_never_started_legacy_ramps_to_new, and #1020 (owner hold) also adds 000127_executions_detach_keeps_updated_at, with #1022 at 000128. Main is at 000126. Whichever lands second must renumber, and golang-migrate must never apply a lower number after a higher one. Proposed order: #905 keeps 000127 (it is closer to merge), then #1020 moves to 000128 and #1022 to 000129. A delta review of 8c0123d (finding 1 fix) is running.

@cristim cristim left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent delta review of 8c0123d (previous: 00eeaeb): no actionable findings (residual below needs an explicit owner acceptance)

Scope: git diff 00eeaeb8..8c0123d1 (6 files: migration 000127 up/down, error text, new migration integration test, two test tweaks). OLD is an ancestor of NEW. git merge-tree origin/main HEAD is conflict-free. The branch lacks main's 000126 but 000127 > 000126, so golang-migrate applies in order.

Previous finding 1 (permanent legacy): PARTIALLY RESOLVED

  • RESOLVED for pre-existing never-started ramps: 000127 up promotes them to new, and the new test shows step 1 then sizes and freezes a baseline.
  • NOT addressed, by design: a ramp that started, or has any execution row, stays legacy for good. This includes the original failing scenario (attach step 1, edit the term before approving: an execution row exists, the trigger degrades the plan, step 2 is refused). There is still no re-trust path; only delete and recreate. Per the owner decision (no backward compatibility) I treat this as accepted, NON-BLOCKING, but it needs the owner's explicit acceptance recorded on the PR.
  • NON-BLOCKING: the new ErrRampLedgerUntrusted text (store_postgres_ramp_baseline.go:35) says the history is "from before the retention ledger". That is inaccurate for plans degraded after the ledger exists by a post-attach shape edit. Suggest neutral wording.

Attack on migration 000127

  1. Rows touched: only legacy AND ramp_ledger_protected (total_steps > 1, current_step < total) AND current_step = 0 AND ramp_baseline IS NULL AND no purchase_executions row of any status for that plan_id. A plan with any executed, approved, in-flight, failed or canceled step has a row and is skipped. Tenant crossing: the predicate is per plan row, with a correlated NOT EXISTS on the same plan_id; no join across tenants. The test covers: has-execution, mid-ramp, single-step, frozen-baseline and trusted plans all unchanged. Residual (documented in the SQL, accepted): pre-000124 retention cleanup may have deleted completed rows of a plan whose step never advanced, so "no rows" is not proof of "never bought". A concurrent old-Lambda execution insert during the UPDATE is not blocked (FK takes ROW SHARE only), but such a plan simply has a visible execution row that the new ledger counts.
  2. DOWN is a deliberate no-op (SELECT 1). It is not an inverse, but it loses nothing: the promoted plans are indistinguishable from freshly created new plans, and demoting them would re-block sizing. Safe after new rows were written. 000124's own down drops the column anyway.
  3. Idempotent: promoted rows no longer match legacy, so a re-run touches 0 rows. Empty table: no-op. Locking: DISABLE/ENABLE TRIGGER takes SHARE ROW EXCLUSIVE on purchase_plans (blocks plan writes, not reads) for the migration; purchase_plans is small and the NOT EXISTS probe uses idx_purchase_executions_plan_id (000002). Only the guard_ramp_ledger_state trigger is disabled; retain_ramp_ledger_execution stays live. The test asserts tgenabled = 'O' afterwards. The migration runner applies the file as one statement batch, so a failure rolls back both toggles; I did not independently confirm the golang-migrate transaction semantics beyond the test passing.
  4. Drain-window caveat: an old Lambda that inserts a plan after 000127 gets legacy again (column default, INSERT guard). Same deploy-note class as before; not a defect in this migration.
  5. Fail-before/pass-after (testcontainers Postgres): TestMigration_NeverStartedLegacyRampsBecomeNew passes on NEW; with the up file replaced by SELECT 1; it fails ("expected new, actual legacy"). Worktree restored. Also ran TestCumulativeAttach|TestFailExisting|TestEqualCount|TestRampStep on NEW: ok. go build ./... ok.

Gate status

  • Independent review: this comment at 8c0123d; the delta is clean; finding 1 residual awaits the owner's acceptance.
  • Migration numbering: #1020 (owner hold) also adds a 000127 migration. Gate item, not a code defect: whichever lands second must renumber (coordinator proposes #905 keeps 000127, #1020 -> 000128, #1022 -> 000129).
  • CI on 8c0123d: run 39042104138 (CI - Build & Test) in progress, pre-commit success; no action_required runs seen. Not waited for. CodeRabbit: nothing posted on this SHA.
  • Labels: unchanged, match #631. Local evidence: macOS testcontainers integration, no real-account verification claimed.

@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Parking this PR. On 2026-10-17 the owner cut #631 to a UI-first ship on the existing save path. Under that cut, #905 is finished only if its review findings are small. Finding 1 (a permanent legacy ledger state with no recovery path) was not small: it needed three owner decisions and two data migrations. So #905 moves to the #631 follow-up hardening issue. Please do not merge it in its current state.

State at head 8c0123d (CI green)

  • Finding 2 is fixed in 00eeaeb, after two clean independent passes.
  • Finding 1, part 1 is in 8c0123d, after two clean passes. Migration 000127 marks never-started legacy ramps 'new' again, and the refusal text is reworded.
  • Finding 1, part 2 is built locally but not reviewed. Mid-ramp legacy ramps become 'trusted' and freeze their baseline from the demand shown at the next step; the owner chose both of those rules. It adds about 217 lines and a second migration, and is parked as a local patch.
  • With part 1 alone, mid-ramp legacy ramps would still halt after deploy. So this PR must not merge until part 2 has been reviewed and added.

Merge-order note for whoever resumes: the deferred #631 M3 branch must renumber its receipts migration above this PR's migrations.

Refs #631

@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Tracked in the #631 follow-up issue #1057 (item 4).

@cristim

cristim commented Oct 17, 2026

Copy link
Copy Markdown
Member Author

Gate record for 8c0123d (origin/main CLAUDE.md "Review gate"):

Merging at 8c0123d with --match-head-commit.

@cristim
cristim merged commit 8832fb0 into main Oct 17, 2026
25 checks passed
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 17, 2026
…loud#905

LeanerCloud#905 merged its own 000127 (never_started_legacy_ramps_to_new), so this
PR's migration moves to 000128. The down step in its test now targets the
real predecessor instead of a hard-coded 126.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 17, 2026
LeanerCloud#905 merged 000127 and LeanerCloud#1020 moves to 000128, so this PR's migration
becomes 000129 (and still merges after LeanerCloud#1020).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cristim pushed a commit that referenced this pull request Oct 17, 2026
…1020)

* fix(config): let an account with only terminal executions be deleted

Migration 000053 made purchase_executions.cloud_account_id ON DELETE
RESTRICT, so one completed/failed/canceled/expired execution blocked
the account delete forever, and the handler answered with a 409 asking
the operator to cancel pending purchases that did not exist.

DeleteCloudAccount now detaches the account's terminal executions
(cloud_account_id = NULL, status in the new TerminalExecutionStatuses
allowlist) in its transaction before the DELETE, keeping them as
history. Live rows stay attached, so the FK still refuses the delete.
The deleteAccount preflight adds CountLiveExecutionsForAccount and
answers a 409 with reason "active_executions" for approved, running,
scheduled or paused rows, after the existing pending/notified 409.

Migration 000127 gives purchase_executions its own updated_at trigger
that keeps updated_at on a detach-only UPDATE, so deleting an account
does not pull old failures back into the plan health window or
restart the retention clock of canceled rows.

Closes #265

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(db): renumber executions-detach migration to 000128 after #905

#905 merged its own 000127 (never_started_legacy_ramps_to_new), so this
PR's migration moves to 000128. The down step in its test now targets the
real predecessor instead of a hard-coded 126.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(api): re-run the delete preflight after an FK-violation race

When DeleteCloudAccount hits SQLSTATE 23503 because a row raced past the
preflight, re-query so the client gets the structured pending (with
counts) or active 409 for that row. Fall back to the bare pending 409 if
the re-query fails or no longer sees a blocker (CodeRabbit on #1020).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
cristim pushed a commit that referenced this pull request Oct 18, 2026
…n state (#1022)

* fix(scheduler): record partial multi-subscription sweeps in collection state

tolerateIncompleteSweep kept the rows of a partial sweep but recorded the
incompleteness only in a WARN log, so the freshness API (and therefore the
dashboard) rendered 1-of-10 subscription coverage exactly like full
coverage.

The helper now returns a sanitized *sweepGap (nil = complete) instead of a
bool. It is threaded through the per-account collectors, fanOutPerAccount,
the three provider collectors and collectAllProviders into a new
recommendations_state.last_collection_partial JSONB column (migration
000128), served as last_collection_partial on GET
/api/recommendations/freshness. The payload carries per-provider counts
(incomplete accounts, attempted/failed scopes, failed details) and a closed
set of reason categories; raw provider errors and subscription/project IDs
stay in the log, because the state row is global while accounts are
access-scoped.

UpsertRecommendations and ReplaceRecommendations reset the column, so the
next complete collection clears the marker. Hard failures are unchanged:
they still fail the provider and populate last_collection_error.

Tests now drive the partial/complete/hard-error cases for AWS, Azure and
GCP (ambient) plus AWS/GCP registered accounts, closing audit finding
A16-007's per-provider coverage gap.

Closes #87

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(purchase): migrate to 000128 before the Azure pricing recollection upsert

UpsertRecommendations now also resets
recommendations_state.last_collection_partial (migration 000128, #87), so
the upsert failed on the v111 schema this test pinned before re-collecting.
Bump the pin to 128, following the test's existing precedent for 000111.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(db): renumber recommendations-state-partial migration to 000129

#905 merged 000127 and #1020 moves to 000128, so this PR's migration
becomes 000129 (and still merges after #1020).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xl Multi-week / refactor impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/feat New capability urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant