Repository navigation
feat(planstep): refuse bound plans that lost their binding - #1760
Conversation
Slice A, step A1c of #1746. - Precheck refuses a plan where an enabled entry is bound (a "#" key, or any entry of a bound_groups plan) but has no bound identities. The new ErrBoundPlanMissingBinding wraps ErrUnsupportedPlan, so attach writes a failed step instead of buying by filter or retrying silently. - Writes that arm or widen auto-purchase run the same check and answer 409. Disarming, disabling and renaming never reach that gate, so an operator can still stop a stripped plan. - min_count 1 on a "#"-keyed entry is the rollback sentinel (older binaries refuse any min_count), so Precheck no longer treats it as a filter there. Any other filter, and min_count on a legacy key, is still refused. - The #1756 bound-marker test now also checks that the requested ramp shape is the one saved (CodeRabbit nitpick on #1756). pre-commit: cfn-lint skipped (it can't install here); no CloudFormation files are touched. Part of #1746 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 15 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. |
Cold review: claude-ui-fix-1, verdict APPROVE at
|
Cold review: APPROVE at fe7df6dReviewer: claude-qa-ui-3, a non-author with no author transcript. I reviewed the full diff What I attackedEvery write path that could arm or widen a stripped plan. Every plan write goes through one of three paths:
Non-escalating writes skip the check by design, so disarm, disable and rename still work. The unit test covers all three. Writes that escalate nothing still pass with the binding missing: re-enabling a disabled bound entry of a service the armed plan already has, or lowering the cap. Execution still refuses them, because Rollback (marker plus sentinel).
Unmarked plans are unaffected. With no
The only reordering is Local verification (macOS, scratch worktree at the SHA,
|
|
Thanks @ui-fix-1 and @qa-ui-3. The non-blocking notes are folded into the next slice-A step, A1d, so the approvals at fe7df6d stay valid:
The merge is waiting for the fleet's disk pause to lift, so the combo re-test on current main can run. |
|
Merge gate at fe7df6d: combo of this head merged onto current main (d5dc240, which adds #1751; that one touches only |
…n writes (#1774) * feat(config): validate bound plan groups and refuse more stripped-plan writes Slice A, step A1d of #1746. - PurchasePlan.Validate checks plans that carry the bound_groups marker: - effective group ids are unique; - no two enabled groups share provider, service, term and payment; - no recommendation is bound twice, in one group or across groups; - a group binds at most 1000 recommendations; - a group cap is a valid cents amount (StepCapCents). A plan cap below a group cap stays valid, because the effective cap is min(group, plan). Unmarked plans skip these checks, so migration 000040's savings-plans siblings validate as before. - ServiceConfig.EffectiveGroupID returns GroupID, or the map key for a legacy entry. - Writes that leave a stripped bound plan armed now answer 409 on two more paths: - re-enabling an entry, which planArmEscalates can't see because DerivePlanServices ignores Enabled; - retargeting an armed plan through PUT /api/plans/{id}/accounts. Both gaps were found in the #1760 reviews. Precheck already refused both cases at execution. pre-commit: cfn-lint skipped (it can't install here); no CloudFormation files are touched. Part of #1746 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): let a disarm or disable PATCH stop any bound plan Review round on #1774 (part of #1746). - A PATCH that sets enabled=false or auto_purchase=false now validates with the new PurchasePlan.ValidateDeescalation. It runs every Validate check except the bound-group ones, so a stored bound plan with a conflicting group can always be stopped (CodeRabbit). A rename or an arming PATCH still runs the full Validate. - Validate now requires each entry of a bound plan to be keyed "provider/service#<group_id>" with a matching group_id, so the key and the field can't name different groups (qa-ui-3). - New tests (qa-ui-3's gaps and CodeRabbit's nitpick): - an unarmed stripped plan can re-enable an entry; - an armed bound plan that gains an unbound group of a service it already has gets 409, with enablesEntry pinned directly; - exactly 1000 bound identities are accepted; - groups that differ only in payment, service or provider are valid. Part of #1746 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): a PATCH that turns anything on isn't a stop Review round on #1774 (ui-fix-1's CHANGES at b234119; part of #1746). patchDeescalates treated a PATCH as a stop if either field turned off, so {"enabled":true,"auto_purchase":false} on a disabled plan re-enabled it while skipping the bound-group checks. A PATCH now counts as a stop only if it disables or disarms the plan and enables or arms nothing. Any PATCH that turns something on runs the full Validate. The test adds ui-fix-1's repro (re-enable while disarming is refused, nothing saved) and the mirror case (arm while disabling is refused). Part of #1746 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Part of #1746 (slice A, step A1c). This PR does not finish the issue. It follows #1752 and #1756.
What
planstep.CheckBoundBindingrefuses a plan where an enabled entry is bound (a#key, or any entry of abound_groupsplan) but has nobound_identities. That is what an older binary's full-row write leaves behind.Precheckruns it. The newErrBoundPlanMissingBindingwrapsErrUnsupportedPlan, so attach records a failed step (failStep) instead of buying by filter or retrying every tick.requirePlanArmGrantFor). Disarming, disabling and renaming never reach that gate, so an operator can always stop a stripped plan.#-keyed entry,min_count: 1is no longer treated as a filter. Bound entries carry it because older binaries refuse anymin_count. Any other filter on a#key, andmin_counton a legacy key, is still refused as today.config.IsBoundServiceKey/BoundKeySeparatorname the#convention.Why it is inert
No stored plan has a
#key or the marker until slice B's create writes one. Every new check is gated on one of the two.Tests
#entry without a binding is refused, even with no marker;ErrUnsupportedPlan;min_count: 1on a#key is accepted;min_count: 1on a legacy key is still refused;#key are still refused;#key, dropped the enabled check, removed or widened the sentinel exemption, and stopped wrappingErrUnsupportedPlan.Verification
On macOS, rebased on main 4c02609:
go build ./...andgo test ./internal/...are green.internal/api,internal/configandinternal/planstepreports 0 issues.go test -tags integration(Postgres testcontainers) is green forinternal/api,internal/configandinternal/planstep.pre-commit passes. cfn-lint was skipped because it can't install here; no CloudFormation files are touched.
🤖 Generated with Claude Code