Skip to content

fix(api/purchases): return a 500 when the approval-email failure cannot be recorded - #1259

Merged
IAmJSD merged 5 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-54
Oct 26, 2026
Merged

IAmJSD merged 5 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-54

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1237

This branch is stacked on #1237 (fix/issue-1232, head 17654ccf) and must merge after #1237. Until #1237 merges, this PR's diff also shows #1237's commit (17654ccf, the guarded SaveExecutionIfStatus write). The change for this issue is the single commit 91827b9a on top. Once #1237 is in main, the diff shrinks to that commit.

Root cause

finalizePurchaseStatus (internal/api/handler_purchases.go) runs after the approval email send on the submit path (executePurchase) and the retry path (retryPurchase). When the send fails, it marks the new execution failed. With #1237 that write is SaveExecutionIfStatus(..., []string{"pending"}). If the write failed for any reason other than a lost guard (connection reset, a deadline, a missing row), the function logged the error and returned "pending". Both callers put that into a 200 response: {"status":"pending","email_sent":false}.

The row really was left pending, with an empty error column and a valid approval token, while no approval email existed. It looked exactly like a healthy request waiting on its approver, and nothing in History or resolveOpsHint explained it until the expiry sweep. This is the same silent save-failure pattern COR-10 fixed in LeanerCloud/cloud-commitments-cli#1237.

Fix

  • finalizePurchaseStatus now returns (string, error). On a save failure (the default branch) it returns a 500 client error built by the new emailFailureNotRecordedError:
    • The message starts with AUDIT LOSS:. It names the execution and says it may still show as pending with no approval email on the way, so cancel it from History.
    • The body also carries execution_id, email_sent: false and email_reason, so the UI or an API client can find the row and cancel it.
    • The store error is wrapped with %w (still reachable with errors.Is and in the server log with an AUDIT LOSS: prefix) but kept out of the response body.
  • executePurchase and retryPurchase return the error instead of a response. On retry, the original row's link to the successor is unchanged, and the 500 names the successor.
  • The email-sent path, fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237's guarded write and its ErrExecutionNotInExpectedStatus (lost guard) branch are unchanged.
  • The submit-path tail (send, finalize, build the response) moved into sendApprovalAndFinalize, so executePurchase stays at gocyclo 10.

Why a 5xx and not failed or a new status: the row was not written, so answering failed would be just as wrong as pending. It is also possible that the write committed and only the acknowledgement was lost, so the server cannot say which state the row is in. A new status value such as failed_send_audit_loss would have to be added to the status set and handled by every client. A 500 is the option the issue and the 2026-07-28 review comment prefer ("the response should be a 5xx so the caller retries or alerts rather than recording a phantom pending"), and it matches how cli#1237 handled the same case (it returns the AUDIT LOSS error). No execution_status values change and there is no migration.

Closes #54

Verification

Run on the Linux VM from the repo root with Go 1.26.9 and AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null. The integration tests used real Postgres in testcontainers. No real email, cloud API or purchase was used: the email failure is the unconfigured-notifier path, and the store failures come from mocks or a canceled context.

New and changed tests:

  • internal/api/handler_purchases_finalize_test.go (unit, mock store):
    • TestFinalizePurchaseStatus_WriteErrorSurfaces replaces fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237's WriteErrorKeepsPending. It checks the 500, the body fields, errors.Is(err, saveErr), that the store error is not in the body, that the caller's copy is not mutated, and that there is no re-read.
    • TestFinalizePurchaseStatus_MissingRowSurfaces: ErrNotFound from the guarded save is also a save failure.
    • TestExecutePurchase_EmailFailureNotRecorded_Returns500 and TestRetryPurchase_EmailFailureNotRecorded_Returns500 drive the full submit and retry handlers. Each one asserts the error and that no success body comes back. They also run the error through handleRequestError, the router's error mapping, and check the status code and body.
    • fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237's tests were updated only for the new two-value return.
  • internal/api/handler_purchases_finalize_race_integration_test.go (integration, real Postgres):
    • TestFinalizePurchaseStatus_SaveFailureSurfaces inserts a real pending row through persistExecutionAndSuppressions and fails the real sendPurchaseApprovalEmail (no notifier). It then runs finalizePurchaseStatus on a canceled context, so the guarded save fails inside pgx. It asserts the 500, errors.Is(err, context.Canceled), and that the stored row is still pending with an empty error. fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237's two integration tests now also require a nil error.

Fail-before / pass-after:

# fail-before: #1237's handler_purchases.go and test files checked out from HEAD (17654ccf),
# plus a temporary file holding only the two new handler tests and their helper
go test ./internal/api/ -count=1 -run 'EmailFailureNotRecorded' -v
# --- FAIL: TestExecutePurchase_EmailFailureNotRecorded_Returns500
#     Expected nil, but got: map[... "email_sent":false ... "status":"pending" ...]
#     An error is expected but got nil.
# --- FAIL: TestRetryPurchase_EmailFailureNotRecorded_Returns500 (same: 200 body with "status":"pending")

# mutation probe on the fix: default branch changed back to `return "pending", nil`
go test -tags integration -count=1 -timeout 20m -run 'TestFinalizePurchaseStatus_SaveFailureSurfaces|EmailFailureNotRecorded|WriteErrorSurfaces|MissingRowSurfaces' ./internal/api/
# all 5 FAIL ("a failed failure write must not be reported as a normal response")

# pass-after at 91827b9a
go test -tags integration -count=1 -timeout 20m -run 'TestFinalizePurchaseStatus|EmailFailureNotRecorded' ./internal/api/ -v
# all PASS, including #1237's ApproveDuringEmailSend and ConcurrentWithApproveClaim (approve claim won 13 of 40 rounds)

The temporary file was deleted afterwards.

Other checks:

go build ./...
go vet ./... && go vet -tags integration ./...
go test ./internal/api/ -count=1                                       # ok
go test ./internal/mocks/ -count=1                                     # ok
go test -short -race -count=1 -run 'TestFinalizePurchaseStatus|EmailFailureNotRecorded|retryPurchase|ExecutePurchase' ./internal/api/   # ok
golangci-lint v2.10.1 run --allow-parallel-runners ./internal/api/...                       # 0 issues
golangci-lint v2.10.1 run --allow-parallel-runners --build-tags integration ./internal/api/...  # 0 issues
gocyclo -over 10 -ignore "_test\.go" internal/api/handler_purchases.go   # silent (executePurchase 10, retryPurchase 10)
bash scripts/gosec-hook.sh internal/api/handler_purchases.go            # exit 0
bash scripts/gofmt-hook.sh <changed files>                              # exit 0

This is unit, mock and local integration evidence only. A real SES failure combined with a real database write failure was not exercised.

Combined-tree verification. This branch was tested together with six other queued branches on a local, never-pushed tree: queue/platform @ 77d116c6 = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-47 62feb4f
fix/issue-76 3b8c241
fix/issue-1233 e1638b7
fix/issue-153 8729074
fix/issue-532 0971624
fix/issue-64 fefdc43
fix/issue-54 91827b9

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks (changed packages): golangci-lint v2.10.1 found 0 issues, with and without --build-tags integration, and gocyclo -over 10 is silent on the changed non-test files. These ran at b61a9c30, which differs from 77d116c6 only by one .gitallowed line on fix/issue-1233.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1):
    • These packages pass: cmd/cleanup-lambda, internal/analytics, internal/auth, internal/database/postgres/migrations and internal/purchase.
    • internal/api passes, except two tests whose Postgres container didn't start, or whose migrations hit a timeout, under machine load. Rerun alone on the same tree, both pass: TestDeleteManagedPlanByExecutionStatus and TestNotifiedRow_RescheduleDoesNotOverwriteConcurrentCancel.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • Packages were run with a 60-minute timeout (rather than CI's 20) because the VM was shared with other test runs. Some reruns used TESTCONTAINERS_RYUK_DISABLED=true to avoid a testcontainers reaper race under load.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Out of scope

  • The approval token on the stranded row stays valid until the expiry sweep or a cancel. The 500 names the execution so it can be canceled. Revoking the token automatically would need another write on a path where the store has just failed, so it is not done here.
  • The issue's "Sweep target", the log-only audit-field stamp in directExecutePurchase, is unchanged. The issue calls it best-effort attribution, and the 2026-07-28 review tracks it in its grouped internal/api issue.
  • The issue body's "the row will expire rather than fire" wording, which the review comment asks to edit, is left for the maintainer.

Frontend follow-up for the UI owner

On this rare path, submit (POST purchase execute) and retry now return HTTP 500 instead of 200. The JSON body is {"error": "AUDIT LOSS: ...", "execution_id": "...", "email_sent": false, "email_reason": "..."}. The UI should already show the error message as a failure. Optionally, when execution_id is present on a 500, link to that execution in History so the user can cancel it.

Maintainers: please mirror the issue's triage labels onto this PR: priority/p3, severity/low, urgency/eventually, impact/internal, effort/s, type/bug, triaged.

🤖 Generated with Claude Code

IAmJSD and others added 2 commits October 22, 2026 01:26
…Status on pending

finalizePurchaseStatus stamped "failed" over a freshly inserted execution
with an unguarded full-row save whenever the approval email could not be
sent. A session approve (pending -> running claim), cancel or pause can
land while the email is in flight; the unguarded write then overwrote the
row, so a commitment that was being bought ended up "failed" and eligible
for retry, risking a second purchase.

The write now goes through SaveExecutionIfStatus with "pending" as the
only expected status (row lock + status check + write in one tx). A lost
guard is a no-op with a warning: the row keeps the other writer's status,
the caller's in-memory copy is left untouched, and the response reports
the row's re-read status instead of a stale "failed". Other save errors
keep the existing best-effort "pending" contract.

Tests: mock unit tests pin the guarded call and the lost-guard behavior;
integration tests on real Postgres drive the real send path with an
approve claim landing during a blocked email send, and race the failed
write against the approve claim for 40 rounds.

Refs LeanerCloud#1232

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot be recorded

When the approval email failed, finalizePurchaseStatus tried to mark the
new execution failed. If that guarded save itself errored (anything other
than a lost status guard), it logged and returned "pending", so submit and
retry answered 200 {"status":"pending","email_sent":false}. The row
really was left pending, with no error text and a valid approval token,
and looked exactly like a healthy request waiting on its approver.

finalizePurchaseStatus now returns (string, error). On a save failure it
returns a 500 client error whose message starts "AUDIT LOSS:" and whose
body carries execution_id, email_sent=false and email_reason, so the
caller can find and cancel the row. The store error is wrapped (still
reachable with errors.Is) but kept out of the response body. executePurchase
and retryPurchase propagate it. The guarded write and the lost-guard
branch are unchanged. The submit-path tail moved into
sendApprovalAndFinalize to keep executePurchase within gocyclo 10.

Tests: mock unit tests for the save-error and missing-row cases, handler
tests for submit and retry that answered 200 "pending" before the fix,
and an integration test on real Postgres where the failure write runs on
a canceled context and the row stays pending.

Closes LeanerCloud#54

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added impact/internal Team-internal only triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline effort/s Hours type/bug Defect labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 3 billable files and costs up to $0.75.

Or wait 53 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between c8bb869 and c4dd963.

Files selected for processing: 3
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_finalize_race_integration_test.go
  • internal/api/handler_purchases_finalize_test.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run.

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

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE 8a7f099e2f7e40f415ef054365f42da9f7603643

Blocking:

  • none

Non-blocking:

Attacked:

  • Error mapping: fmt.Errorf("%w: %w", ce, saveErr). I checked that handleRequestError runs IsNotFoundError first, but that matches only the api package's notFoundError type, so a config.ErrNotFound from the save still maps to the structured 500, not a 404 (the MissingRow test drives it through handleRequestError). The store error stays out of the body, and email_reason was already in the old 200 body, so nothing new leaks. The frontend (responseError) shows the specific AUDIT LOSS: message and does not auto-retry a 5xx POST, so there's no duplicate-submit loop.
  • Concurrency (fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237 part): SaveExecutionIfStatusTx row-locks and checks pending before the write, so a session approve that wins during the email send is never overwritten with failed. Both create paths insert Status: "pending", so the ["pending"] guard covers the window.
  • Fail-before: I changed the default branch back to return "pending", nil. TestFinalizePurchaseStatus_WriteErrorSurfaces, ..._MissingRowSurfaces, TestExecutePurchase_EmailFailureNotRecorded_Returns500 and TestRetryPurchase_EmailFailureNotRecorded_Returns500 all fail. Restoring the file makes them pass.
  • Ran go build ./..., go vet ./internal/api/ (plain and -tags integration), go test -race -short -count=1 ./internal/api/ and go test -race -tags integration -count=1 ./internal/api/ (testcontainers; includes the canceled-context real-Postgres test and fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237's race tests): all ok. The head merges cleanly with current main (35 behind) and with fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237's current head 3086bc8.
  • Against issue fix(purchase): finalizePurchaseStatus silently swallows save-failure (mirrors COR-10) #54's acceptance: the save failure is no longer reported as pending, the caller gets an AUDIT LOSS 5xx, and a regression test fails against the old behavior. Closes #54 is justified.

Body-stale:

Reviewer: rB9. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE 8a7f099e2f7e40f415ef054365f42da9f7603643

Blocking:

  • none (in the change itself; see the merge state below)

Non-blocking:

Attacked:

  • Error mapping: fmt.Errorf("%w: %w", ce, saveErr). IsClientError uses errors.As, so handleRequestError returns the structured 500 (execution_id, email_sent:false, email_reason) and the store error stays out of the body. config.ErrNotFound from the save still maps to the 500, not a 404 (MissingRowSurfaces drives this through handleRequestError). The lost-guard branch (ErrExecutionNotInExpectedStatus) is unchanged, so a concurrent approve during the email send is never overwritten and never turned into a 500. Not a money path: the row still needs approval, and no purchase can fire from this branch.
  • Fail-before: I changed the default branch back to return "pending", nil. TestFinalizePurchaseStatus_WriteErrorSurfaces, ..._MissingRowSurfaces, TestExecutePurchase_EmailFailureNotRecorded_Returns500 and TestRetryPurchase_EmailFailureNotRecorded_Returns500 failed. They passed after restoring.
  • Ran go build ./..., go vet (plain and -tags integration) on internal/api, go test -race -short -count=1 ./internal/api/ (ok), golangci-lint with and without the integration tag (0 issues), gocyclo -over 10 on handler_purchases.go (silent), and go test -race -tags integration -count=1 ./internal/api/ (ok, real Postgres, including the canceled-context save-failure test and fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237's race tests). I checked mergeability with git merge-tree --write-tree origin/main HEAD: 3 conflicts.

Body-stale:

Reviewer: rB24. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

IAmJSD and others added 2 commits October 26, 2026 04:36
This branch carried LeanerCloud#1237's commit (17654cc), which main now has as
the squash a59740d. Conflicts in handler_purchases.go and the two
finalize test files were resolved by taking main's side for LeanerCloud#1237's
changes and re-applying only this PR's own commit (91827b9) on top, so
the PR diff against main is exactly that commit.

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

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merged current main (c8bb869) into the branch, in two merge commits: 845adbb, then c4dd963 for #1426. New head: c4dd963a1ea47e4537b1a4b2da387310aab643e7.

Resolution. This branch carried #1237's commit (17654cc). Main now has that change as the squash a59740d, which caused conflicts in internal/api/handler_purchases.go, handler_purchases_finalize_test.go and handler_purchases_finalize_race_integration_test.go. I resolved them like this:

The order hold is satisfied: #1237 is merged.

Checks on the merged tree, with no cloud credentials:

  • go build ./..., go vet ./..., go vet -tags integration ./... and the e2e vet all pass.
  • golangci-lint v2.10.1 (with and without the integration tag) and gocyclo -over 10 are clean on internal/api.
  • go test -race -short passes on internal/api.
  • The full go test -race -tags integration run passes on internal/api (testcontainers Postgres).
  • The secrets scan prints "clean; planted key flagged", and the allowlist test passes.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE c4dd963a1ea47e4537b1a4b2da387310aab643e7

Blocking:

  • none

Non-blocking:

  • Stranded row on the 500: the execution stays pending with a valid approval-token hash and no email, so an approver can still approve it from the dashboard. Clicking Submit again after the 500 creates a second pending row for the same recs. The 500 message tells the user to cancel the first row from History, which is the right steer. Neither case is new: the old 200 pending/email_sent:false left the same row behind, just more quietly. An earlier review raised the resubmit point too.
  • On retry, the original row already points at the stranded successor. The body discloses this.

Attacked:

  • Error mapping: fmt.Errorf("%w: %w", ce, saveErr). handleRequestError (the only consumer) runs IsNotFoundError first, but that only matches the api package's own notFoundError type. So a config.ErrNotFound from the save still reaches IsClientError (errors.As) and becomes the structured 500 with execution_id, email_sent:false and email_reason, and the store error stays out of the body. The frontend's client.ts shows a non-generic error message on a 5xx and keeps the other keys as details. It has no automatic 5xx retry, so nothing re-submits on its own. The lost-guard branch (fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237, already on main) and the email-sent path are unchanged.
  • Ran in a scratch worktree at c4dd963: go build ./...; go vet ./internal/api/ with and without -tags integration; go test -race -short -count=1 ./internal/api/ (ok); go test -race -tags integration -count=1 -run 'TestFinalizePurchaseStatus|EmailFailureNotRecorded|ExecutePurchase|RetryPurchase' ./internal/api/ (ok, real Postgres); golangci-lint with and without the integration tag (0 issues). git merge-tree against origin/main 8368330: clean. The three-dot diff is exactly 91827b9's 3 files.
  • Fail-before: I changed the default branch of finalizePurchaseStatus back to return "pending", nil. Then TestFinalizePurchaseStatus_SaveFailureSurfaces (real Postgres, canceled context), ..._WriteErrorSurfaces, ..._MissingRowSurfaces, TestExecutePurchase_EmailFailureNotRecorded_Returns500 and TestRetryPurchase_EmailFailureNotRecorded_Returns500 all fail. Restored afterwards.
  • Against fix(purchase): finalizePurchaseStatus silently swallows save-failure (mirrors COR-10) #54's acceptance: a save failure is no longer reported as pending, the caller gets an AUDIT LOSS 5xx, and a regression test fails on the old behavior. Closes #54 is justified.

Body-stale:

Reviewer: rC5. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE c4dd963a1ea47e4537b1a4b2da387310aab643e7

Blocking:

  • none

Non-blocking:

  • A pending row is stranded with no email and a valid token, and a session approver can still approve it from the dashboard. The 500 tells the submitter to cancel it, but nothing stops a resubmit while the first row is still approvable. The result would be two pending rows for the same recs, both session-approvable. The 200 {status: pending, email_sent: false} answer on main had the same exposure, so this PR doesn't make it worse. The body leaves automatic token revocation out of scope, and a follow-up should decide whether that row should also be hidden from session approve.
  • openapi.yaml isn't touched. If the execute and retry operations don't already list a structured 500, documenting the execution_id/email_sent/email_reason body would help API clients. Optional.

Attacked:

  • Read the diff against the merge-base, which now holds only this issue's change because fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237 is merged. I checked the error plumbing: IsClientError uses errors.As, so the %w: %w wrap still yields the 500 with flattened details. IsNotFoundError matches only the router's own notFoundError type, so a wrapped config.ErrNotFound from the guarded save doesn't turn into a 404, and MissingRowSurfaces pins that. The store error stays out of the body. A ClientError isn't logged by handleRequestError, but the default branch logs AUDIT LOSS: itself. The lost-guard branch from fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237 and the email-sent path are unchanged. The frontend apiRequest doesn't auto-retry 5xx POSTs.
  • Ran go build ./..., go vet with and without -tags integration on ./internal/api/, and go test -race -short -count=1 ./internal/api/ (ok). Ran go test -race -tags integration -run 'TestFinalizePurchaseStatus|EmailFailureNotRecorded' ./internal/api/ (11 tests, ok on testcontainers Postgres, including fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending #1237's race test). The branch merges cleanly into current main.
  • Fail-before (mutation, because the tests use the new two-value return): I put back return "pending", nil in the default branch. These failed: SaveFailureSurfaces (integration), WriteErrorSurfaces, MissingRowSurfaces, TestExecutePurchase_EmailFailureNotRecorded_Returns500 and TestRetryPurchase_EmailFailureNotRecorded_Returns500. With the fix restored, all pass.

Body-stale:

Reviewer: rC6. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at c4dd963a1ea47e4537b1a4b2da387310aab643e7 merged onto main (89e11403), together with #1191, #1257, #1259 in that order (combined tree f2c1ffb3, Linux, Go 1.26.10): go build ./..., go vet with and without -tags integration, the full go test -race -short -count=1 ./..., and go test -race -tags integration -count=1 on every changed package, against Docker Postgres. All pass. CI Success and pre-commit are green on this head, with 2 approving independent review(s) on it. Nothing was run against any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 26, 2026 08:02
@IAmJSD
IAmJSD merged commit 644ae1e into LeanerCloud:main Oct 26, 2026
41 checks passed
@IAmJSD

IAmJSD commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on c4dd963a (two required: money, auth or security path), green CI, and the combined-tree verification above. Squash-merged at exactly c4dd963a1ea47e4537b1a4b2da387310aab643e7.

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

Labels

effort/s Hours impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/bug Defect urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(purchase): finalizePurchaseStatus silently swallows save-failure (mirrors COR-10)

1 participant