Problem
handler_purchases.go:1594 finalizePurchaseStatus mirrors the COR-10 (LeanerCloud/cloud-commitments-cli#1184 / PR LeanerCloud/cloud-commitments-cli#1237) anti-pattern. When the approval email fails to send, it stamps Status="failed" on the row and calls SavePurchaseExecution. If that save fails, the function logs an error and returns "pending" to the API caller:
execution.Status = "failed"
execution.Error = emailReason
if err := h.config.SavePurchaseExecution(ctx, execution); err != nil {
logging.Errorf("failed to mark execution %s as failed after email send error: %v", execution.ExecutionID, err)
return "pending"
}
return "failed"
The row remains in whatever its previous status was (typically pending) and the API call appears successful to the caller, while:
- the failed-email reason is not persisted onto the row,
- the dashboard/history UI sees no signal that the approval email failed,
- the row sits in
pending until the History expiry sweep (LeanerCloud/cloud-commitments-cli#1158) reaps it, with no breadcrumb explaining why.
This is not on the money path: the email never went out so no human approval link can be clicked, and the row will expire rather than fire. But it is the same silent-failure shape COR-10 closed on the credential-resolution path, so the audit-trail loss is identical.
Suggested fix
Mirror PR LeanerCloud/cloud-commitments-cli#1237: bubble the save error so the caller can act on it (or at minimum return "failed_send_audit_loss" instead of "pending", plus an explicit error response). The two known shapes are:
- Return the save error from
finalizePurchaseStatus. Change the signature to (string, error) and surface an AUDIT LOSS: error to the HTTP handler so the response carries a 5xx with the audit-loss reason. Callers at handler_purchases.go:1120 and :1927 would propagate the error to the existing error path.
- Persist via the atomic
TransitionExecutionStatus(["pending","notified"], "failed", ...) CAS. This is what approvals.go / manager.go use elsewhere and gives an atomic guard against a concurrent approve clobbering the failed-state write.
Either approach removes the silent-failure window. (1) is the minimal change; (2) aligns the email-send-failed path with the rest of the failed-status state machine.
Sweep target
This file (internal/api/handler_purchases.go) and handler_purchases.go:2004 (direct-execute audit-fields stamp) are the two remaining log-only SavePurchaseExecution error sites in the handler_purchases package. The latter is best-effort attribution; this one is a real status-write.
References
Acceptance
finalizePurchaseStatus's save-failure no longer returns "pending" silently; the API caller sees either an error or a failed_send_audit_loss status.
- Regression test that asserts the new contract: with
SavePurchaseExecution returning an error, the response carries the save-failure (and the test fails against the current code).
Problem
handler_purchases.go:1594finalizePurchaseStatusmirrors the COR-10 (LeanerCloud/cloud-commitments-cli#1184 / PR LeanerCloud/cloud-commitments-cli#1237) anti-pattern. When the approval email fails to send, it stampsStatus="failed"on the row and callsSavePurchaseExecution. If that save fails, the function logs an error and returns"pending"to the API caller:The row remains in whatever its previous status was (typically
pending) and the API call appears successful to the caller, while:pendinguntil the History expiry sweep (LeanerCloud/cloud-commitments-cli#1158) reaps it, with no breadcrumb explaining why.This is not on the money path: the email never went out so no human approval link can be clicked, and the row will expire rather than fire. But it is the same silent-failure shape COR-10 closed on the credential-resolution path, so the audit-trail loss is identical.
Suggested fix
Mirror PR LeanerCloud/cloud-commitments-cli#1237: bubble the save error so the caller can act on it (or at minimum return
"failed_send_audit_loss"instead of"pending", plus an explicit error response). The two known shapes are:finalizePurchaseStatus. Change the signature to(string, error)and surface anAUDIT LOSS:error to the HTTP handler so the response carries a 5xx with the audit-loss reason. Callers athandler_purchases.go:1120and:1927would propagate the error to the existing error path.TransitionExecutionStatus(["pending","notified"], "failed", ...)CAS. This is whatapprovals.go/manager.gouse elsewhere and gives an atomic guard against a concurrent approve clobbering the failed-state write.Either approach removes the silent-failure window. (1) is the minimal change; (2) aligns the email-send-failed path with the rest of the failed-status state machine.
Sweep target
This file (
internal/api/handler_purchases.go) andhandler_purchases.go:2004(direct-execute audit-fields stamp) are the two remaining log-onlySavePurchaseExecutionerror sites in thehandler_purchasespackage. The latter is best-effort attribution; this one is a real status-write.References
Acceptance
finalizePurchaseStatus's save-failure no longer returns"pending"silently; the API caller sees either an error or afailed_send_audit_lossstatus.SavePurchaseExecutionreturning an error, the response carries the save-failure (and the test fails against the current code).