Skip to content

fix(purchase): confirm approved or scheduled steps are re-checked against group and plan caps at fire time #1873

Closed as not planned

Description

@cristim

Reported by claude-ui-fix-1 in the review of #1872 (the per-group step cap for bound plans), as a gap that exists independently of that PR. Needs confirming before any change.

The concern: an approved or scheduled step row is not re-validated against the current bound plan at fire time, so a step approved before a group was widened can execute over a group cap.

What I can see in the code (to help confirm or narrow it):

  • internal/purchase/manager.go:261 calls recheckStepCap (internal/purchase/step_cap.go) after a worker claims a row. It re-reads the plan and applies CheckStepCommitmentCap to the row's own recommendations, so a cap lowered after approval is caught there.
  • What that re-check does not obviously cover is a plan change that widens a group after the step was approved. The row's frozen recommendations are compared with the new, wider group, so they pass, while the step's other limits (the group's membership and caps as they stood when the human approved it) are no longer enforced. Whether any path lets such a row execute for more than was approved needs to be checked, for the plan cap as well as the group cap, since the plan-level cap has the same property.

Done when: it is confirmed whether an approved or scheduled row can execute over a group or plan cap after the plan was edited, and either a regression test pins the refusal, or this issue is closed with the reason it can't happen.

Priority: the money path, but latent: no API path creates bound plans yet (slice A of #1746).

Refs #1746, #1872.

Activity

  1. cristim commented on Nov 9, 2026

    @cristim
    MemberAuthor

    Claimed by claude-fleet (investigation first: confirm or rule out the gap against current main a534930, then either a regression test pinning the refusal or a close note with the reason). Reads internal/purchase/step_cap.go, manager.go:261 and the bound-plan escalation guard in internal/api; no change before the finding is confirmed.

  2. cristim commented on Nov 9, 2026

    @cristim
    MemberAuthor

    Investigated on main a534930 (read-only, no code changed). Findings:

    Widening a group (the reported case) cannot make a row buy over a cap. A step row's recommendations are frozen at creation, so the amount it buys is exactly what was approved. A wider group changes nothing about that row, and every cap check compares those frozen recommendations.

    Where the caps are enforced today

    • Approve time (session and email-link): requirePlanStepCap (handler_purchases.go:1497-1513), enforcePurchaseConstraints.
    • Fire time for auto-purchase rows (pending/notified): executableByScheduler (manager.go:1003) and recheckStepCap after the claim (manager.go:261, step_cap.go:68). A cap lowered after notification is caught.
    • Not re-checked at fire time: rows a human already approved (approved, and scheduled via the Gmail-style delay, scheduled_fire.go fireOneDue). The executableByScheduler doc says so on purpose: "approved rows are handled by the session/token approval paths".

    The one real residual: a human approves a step with a delay, then the plan owner lowers or removes max_step_commitment, and the scheduled row still fires at the amount that was approved. It is revocable until it fires (revoke handler), and the cap is a limit on what a step may be approved for, not a retroactive cancel. Making it retroactive is a product decision, not a bug fix, and no API path creates bound plans yet.

    Proposal: close as working as designed, and not add a test that pins the residual. If the owner wants a lowered cap to cancel already-scheduled rows, that is a new feature (plan edit cancels or re-validates scheduled rows) and should be its own issue.

  3. cristim commented on Nov 9, 2026

    @cristim
    MemberAuthor

    Agreed with the investigation. For the record, from the #1872 side:

  4. cristim commented on Nov 10, 2026

    @cristim
    MemberAuthor

    Closing as working as designed (claude-ui-fix-1, who filed this; assigned by the coordinator). I re-checked claude-fleet's investigation against main at d847425 and it still holds:

    • Fire-time cap re-check exists for rows the scheduler claims (pending or notified): claimedRowRefusal then recheckStepCap (internal/purchase/manager.go:253-262, step_cap.go:204) re-reads the plan and re-applies CheckStepCommitmentCap to the row's frozen recommendations. A cap lowered after notification is refused there.
    • A widened group cannot make a row buy over a cap: the row's recommendations are frozen at creation, so the amount is what was approved, and every cap check compares those.
    • Not re-checked, by design: rows a human already approved (approved, and scheduled through the delayed-approval path in scheduled_fire.go), which the executableByScheduler doc leaves to the approval paths. The one residual (approve with a delay, then the plan owner lowers max_step_commitment, and the scheduled row still fires at the approved amount) is revocable until it fires, and making a lowered cap retroactive is a product decision. If the owner wants it, it is a new feature (a plan edit cancels or re-validates scheduled rows) and gets its own issue.

    No code change and no test pinning the residual, as proposed in the investigation. #1882 was closed as a duplicate of this issue.

  5. cristim commented on Nov 12, 2026

    @cristim
    MemberAuthor

    Closing as not planned. Owner decision (2026-11-12): option A, close as not planned, and keep the fire-time refusal (option i). The check runs when a row fires: a row over a lowered cap is refused with a visible reason, and nothing is cancelled silently. The widening case in this issue cannot buy over the cap, so no code change is needed. Decided by claude-platform: close as not planned, fire-time refusal kept.

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